From 788ed296c73fffc4f96e86cd3ab286bdd0986ff5 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 20:45:50 +0530 Subject: [PATCH 01/26] feat(mcp-cli): add generated CLI with dual auth over Streamable HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds atlan_cli.py and SKILL.md to mcp-cli/ — a standalone CLI for calling all 30 Atlan MCP tools directly from the terminal. Auth modes (controlled via env vars): - API key: ATLAN_BASE_URL + ATLAN_API_KEY → /mcp/api-key with Bearer auth - OAuth (PKCE): ATLAN_BASE_URL only → /mcp with browser-based login Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/SKILL.md | 504 ++++++++++++++++++++++++++++++ mcp-cli/atlan_cli.py | 724 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1228 insertions(+) create mode 100644 mcp-cli/SKILL.md create mode 100755 mcp-cli/atlan_cli.py diff --git a/mcp-cli/SKILL.md b/mcp-cli/SKILL.md new file mode 100644 index 0000000..972ef6d --- /dev/null +++ b/mcp-cli/SKILL.md @@ -0,0 +1,504 @@ +--- +name: "mcp.atlan.com-cli" +description: "CLI for the mcp.atlan.com MCP server. Call tools, list resources, and get prompts." +--- + +# mcp.atlan.com CLI + +## Tool Commands + +### semantic_search_tool + +PREFERRED search tool — use this FIRST for any discovery or lookup query. Handles natural language, fuzzy matching, typos, abbreviations, multi-word names, and glossary term lookups. Covers all asset types: tables, columns, views, schemas, dashboards, glossary terms, categories, domains, data products, and more. NOT for users or groups — use resolve_metadata_tool for those. Only fall back to search_assets_tool if you need exact attribute filtering, aggregations, or structured conditions that semantic search cannot express. This tool accepts ONLY: user_query (required), limit, offset, include_readme. It does NOT accept asset_type, conditions, search_query, or any other parameters. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool semantic_search_tool --user-query --limit --offset --include-readme +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--user-query` | string | yes | Natural language search query | +| `--limit` | string | no | Max results to return (default 10, max 20) (JSON string) | +| `--offset` | string | no | Skip first N results for pagination (JSON string) | +| `--include-readme` | boolean | no | Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency. | + +### query_deep_sql_tool + +Execute Gold Layer SQL for pagination in the deep query results widget. Internal tool. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool query_deep_sql_tool --sql --limit --offset +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--sql` | string | yes | SELECT SQL query to execute against the Gold Layer. | +| `--limit` | integer | no | Max rows to return. | +| `--offset` | integer | no | Row offset for pagination. | + +### search_assets_tool + +BACKUP search tool — only use when the primary search tools (start_deep_query_tool or semantic_search_tool) cannot express what you need. Provides structured asset search with exact filters, conditions, aggregations, sorting, and pagination. Use this ONLY for precise attribute filters (e.g. certificateStatus, connectorName), term_guids, tags, domain_guids, count_only, or aggregations that the primary tools don't support. + +LINKED/UNLINKED TERMS — MANDATORY 2-CALL FLOW: +When the user asks about linked, unlinked, orphan, or unused glossary terms you MUST make TWO separate calls: +Call 1: Get terms — search_assets(asset_type="AtlasGlossaryTerm", glossary_qualified_name="", include_attributes=["qualifiedName","name"], limit=100) +Call 2: Get linked term QNs — search_assets(aggregations={"linked_terms": {"field": "__meanings", "size": 500}}, limit=1) — NO asset_type, NO glossary filter. This aggregates __meanings across ALL assets in the catalog. +Then DIFF: terms from Call 1 whose qualifiedName appears in Call 2's aggregation buckets are LINKED. Terms absent are UNLINKED. +IMPORTANT: Call 2 must NOT have asset_type or glossary_qualified_name filters — __meanings lives on regular assets (Tables, Columns), not on terms. Scoping Call 2 to terms returns empty buckets. + +GLOSSARY TERM WORKFLOWS: +- Filter terms by category: conditions={"__categories": ""}. NOTE: __categories only stores the DIRECT parent category. To include subcategories, first fetch all categories in the glossary (asset_type="AtlasGlossaryCategory", include_attributes=["qualifiedName","parentCategory"]), walk the parentCategory tree to find all descendants, then use conditions={"__categories": {"operator": "within", "value": [allDescendantQNs]}}. +NOT for users/groups — use resolve_metadata_tool for those. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool search_assets_tool --conditions --negative-conditions --some-conditions --min-somes --include-attributes --asset-type --glossary-qualified-name --include-archived --limit --offset --sort-by --sort-order --sort --connection-qualified-name --tags --directly-tagged --domain-guids --date-range --guids --term-guids --aggregations --count-only --scroll --include-readme --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--conditions` | string | no | Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {"__categories": ""} (direct only) or {"__categories": {"operator": "within", "value": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags. (JSON string) | +| `--negative-conditions` | string | no | Exclusion filters (same format as conditions) (JSON string) | +| `--some-conditions` | string | no | OR-style filters requiring min_somes matches (JSON string) | +| `--min-somes` | integer | no | Minimum some_conditions to match | +| `--include-attributes` | string | no | Attributes to return (e.g., owner_users, columns, readme). For category hierarchy traversal include parentCategory and qualifiedName. (JSON string) | +| `--asset-type` | string | no | Asset type(s) to search. Single type or list of types. (JSON string) | +| `--glossary-qualified-name` | string | no | Glossary qualifiedName to scope search to terms/categories within that glossary (JSON string) | +| `--include-archived` | boolean | no | Include archived/deleted assets | +| `--limit` | integer | no | Max results to return (default 10, max 20) | +| `--offset` | integer | no | Pagination offset | +| `--sort-by` | string | no | Single field to sort by (JSON string) | +| `--sort-order` | string | no | Sort order | +| `--sort` | string | no | Multi-field sort (JSON string) | +| `--connection-qualified-name` | string | no | Filter by connection (e.g., default/snowflake/123) (JSON string) | +| `--tags` | string | no | Filter by Atlan tag names (JSON string) | +| `--directly-tagged` | boolean | no | Only directly tagged (not inherited) | +| `--domain-guids` | string | no | Filter by domain GUIDs. For DataProduct/DataDomain asset types, this recursively includes all sub-domains — e.g. passing a parent domain GUID returns products under that domain AND all its sub-domains at any depth. (JSON string) | +| `--date-range` | string | no | Date filters (JSON string) | +| `--guids` | string | no | Filter by specific asset GUIDs (JSON string) | +| `--term-guids` | string | no | Filter by assigned glossary term GUIDs (JSON string) | +| `--aggregations` | string | no | Aggregations to compute. Use __meanings aggregation to find which glossary terms are linked to assets. (JSON string) | +| `--count-only` | boolean | no | Return only count, no results | +| `--scroll` | boolean | no | Enable scroll mode for >10k results | +| `--include-readme` | boolean | no | Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### traverse_lineage_tool + +Traverse upstream or downstream lineage from an asset. + +Returns a widget-ready graph with data assets connected by lineage edges. +Process connector nodes are bridged in the graph (A -> B instead of +A -> Process -> B); their entity data is exposed in the ``process_map`` +field and each relation carries ``via_process_guids`` identifying which +ETL/transformation processes produced the connection. The lineage widget +supports progressive loading — leaf nodes show an expand button that +fetches deeper lineage on demand. Keep size small (5-15) for responsive +results; do NOT request all lineage at once. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool traverse_lineage_tool --guid --direction --depth --size --immediate-neighbors --offset --include-attributes --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--guid` | string | yes | GUID of the starting asset | +| `--direction` | string | yes | Lineage traversal direction | +| `--depth` | integer | no | Maximum depth to traverse | +| `--size` | integer | no | Maximum number of data assets to show in the lineage graph. The widget supports progressive loading — users can click expand on leaf nodes to load more. Keep this small (5-15). Max 20. | +| `--immediate-neighbors` | boolean | no | Only return immediate neighbors (one hop) | +| `--offset` | integer | no | Pagination offset for relations (default 0) | +| `--include-attributes` | string | no | Additional attributes to include (JSON string) | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### query_assets_tool + +Execute SQL queries against Atlan connections to preview data. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool query_assets_tool --sql --connection-qualified-name --default-schema --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--sql` | string | yes | SQL query to execute against the connection | +| `--connection-qualified-name` | string | yes | Qualified name of the connection to query | +| `--default-schema` | string | no | Default schema for unqualified table references (JSON string) | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### resolve_metadata_tool + +Use this to search for users and groups, and to get exact usernames and group names before making updates. Also use for extra discovery on classifications, business_metadata, glossary, and data_domain_and_product when semantic_search doesn't return needed results, or before write operations to confirm exact names and GUIDs. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool resolve_metadata_tool --namespace-type --query --limit --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--namespace-type` | string | yes | Metadata namespace to search. Use 'data_domain_and_product' for BOTH data domains AND data products. | +| `--query` | string | yes | Search query - name, description, or natural language | +| `--limit` | integer | no | Max results (default 10, max 20) | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### search_atlan_docs_tool + +Search Atlan's customer-facing documentation and return an LLM-generated answer with source citations. Use for how-to questions about Atlan features — not for searching data assets (use semantic_search_tool for that). + +```bash +uv run --with fastmcp python atlan_cli.py call-tool search_atlan_docs_tool --query --top-k --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--query` | string | yes | Question about Atlan features, how-tos, or configuration. E.g. 'How do I connect Snowflake to Atlan?' or 'How do I create a data product?' | +| `--top-k` | integer | no | Number of documentation sources to retrieve (1-10) | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### get_groups_tool + +Get workspace groups and their members. Use include_members=True to list users in a group. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool get_groups_tool --name-filter --group-id --include-members --limit --offset --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--name-filter` | string | no | Filter by name (partial match) (JSON string) | +| `--group-id` | string | no | Get specific group by ID (GUID) (JSON string) | +| `--include-members` | boolean | no | Include group members in response | +| `--limit` | integer | no | Maximum results (1-250) | +| `--offset` | integer | no | Pagination offset | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### get_asset_tool + +Get detailed information about a single asset by its GUID or qualified name. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool get_asset_tool --guid --qualified-name --asset-type --include-attributes --include-dq-checks --include-readme --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--guid` | string | no | Asset GUID to retrieve (JSON string) | +| `--qualified-name` | string | no | Asset qualified name to retrieve (e.g. default/snowflake/1234/DB/SCHEMA/TABLE) (JSON string) | +| `--asset-type` | string | no | Asset type (required when using qualified_name). e.g. Table, Column, View, Database, Schema (JSON string) | +| `--include-attributes` | string | no | Additional attributes to include (JSON string) | +| `--include-dq-checks` | boolean | no | Set true to include linked data quality checks (Soda, Anomalo, Monte Carlo, Atlan native DQ rules) for this asset | +| `--include-readme` | boolean | no | Set true to fetch and return README content for the asset. Only enable when the user explicitly asks for README/documentation content — adds latency. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### update_assets_tool + +Update attributes on one or more assets. Each item specifies the asset identity + fields to change. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool update_assets_tool --updates --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--updates` | string | yes | Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly 'append' (link terms), 'remove' (unlink terms), or 'replace' (overwrite all). Do NOT use 'add' or 'delete' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is 'append', 'replace', or 'remove' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool. (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### create_glossaries + +Create one or more glossaries in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool create_glossaries --glossaries --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--glossaries` | string | yes | Glossary definitions (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### create_glossary_terms + +Create one or more glossary terms in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool create_glossary_terms --terms --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--terms` | string | yes | Term definitions (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### create_glossary_categories + +Create one or more glossary categories in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool create_glossary_categories --categories --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--categories` | string | yes | Category definitions (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### create_domains + +Create one or more data domains in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool create_domains --domains --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domains` | string | yes | Domain definitions (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### create_data_products + +Create one or more data products in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool create_data_products --products --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--products` | string | yes | Product definitions (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### create_dq_rules_tool + +Create data quality rules in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool create_dq_rules_tool --rules --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--rules` | string | yes | DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is 'Custom SQL'. rule_name (str): display name, required when rule_type is 'Custom SQL'. dimension (str): required when rule_type is 'Custom SQL': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description. (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### schedule_dq_rules_tool + +Schedule data quality rule execution on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool schedule_dq_rules_tool --schedules --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--schedules` | string | yes | Schedule definitions (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### update_dq_rules_tool + +Update existing data quality rules. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool update_dq_rules_tool --rules --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--rules` | string | yes | DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description. (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### delete_dq_rules_tool + +Delete data quality rules by their GUIDs. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool delete_dq_rules_tool --rule-guids --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--rule-guids` | string | yes | GUIDs of DQ rules to delete (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### manage_asset_lifecycle_tool + +Manage asset lifecycle: archive, restore, or permanently purge assets. WARNING: PURGE cannot be undone. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool manage_asset_lifecycle_tool --operation --guids --asset-type --qualified-name --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--operation` | string | yes | Lifecycle operation to perform | +| `--guids` | string | no | Asset GUIDs (required for ARCHIVE/PURGE) (JSON string) | +| `--asset-type` | string | no | Asset type (required for RESTORE) (JSON string) | +| `--qualified-name` | string | no | Qualified name (required for RESTORE) (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### manage_announcements_tool + +Add or remove announcements (information, warning, issue) on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool manage_announcements_tool --asset-guids --operation --announcement-type --title --message --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--asset-guids` | string | yes | Asset GUIDs to update (comma-separated or JSON array) | +| `--operation` | string | yes | Announcement operation | +| `--announcement-type` | string | no | Announcement type (required for SET) (JSON string) | +| `--title` | string | no | Announcement title (required for SET) (JSON string) | +| `--message` | string | no | Announcement message (optional) (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### update_custom_metadata_tool + +Update custom metadata on one or more assets. Set replace=True for full replacement, False (default) for partial update. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool update_custom_metadata_tool --updates --replace --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--updates` | string | yes | Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {"guid": "...", "custom_metadata_name": "CM", "attributes": {"k": "v"}}. Batch: [{"guid": "g1", ...}, {"guid": "g2", ...}] (JSON string) | +| `--replace` | boolean | no | If False (default): partial update — only specified attributes are changed. If True: full replacement — ALL attributes in the CM set are replaced; unspecified attributes are cleared. replace=True only supported for single asset (dict input), not batch. | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### remove_custom_metadata_tool + +Remove a custom metadata set from an asset. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool remove_custom_metadata_tool --guid --custom-metadata-name --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--guid` | string | yes | GUID of the asset | +| `--custom-metadata-name` | string | yes | Name of the custom metadata set to remove | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### create_custom_metadata_set_tool + +Create one or more custom metadata sets with typed attributes. Defines the schema; use update_custom_metadata to set values on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool create_custom_metadata_set_tool --sets --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--sets` | string | yes | Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string\|int\|float\|boolean\|date\|enum\|users\|groups\|url\|SQL\|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {"display_name": "Data Quality", "attributes": [{"display_name": "Score", "attribute_type": "int"}, {"display_name": "Dimension", "attribute_type": "enum", "enum_values": ["Accuracy", "Completeness"]}]}. Batch: [{"display_name": "CM1", "attributes": [...]}, {"display_name": "CM2", "attributes": [...]}] (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### delete_custom_metadata_set_tool + +Permanently delete one or more custom metadata sets and all their attribute values from assets. Irreversible. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool delete_custom_metadata_set_tool --sets --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--sets` | string | yes | Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: "Data Quality". Batch: ["Data Quality", "Governance"] (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### add_attributes_to_cm_set_tool + +Add new typed attributes to an existing custom metadata set. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool add_attributes_to_cm_set_tool --display-name --attributes --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--display-name` | string | yes | Display name of the existing CM set | +| `--attributes` | string | yes | Attributes to add. Each requires: display_name, attribute_type (string\|int\|float\|boolean\|date\|enum\|users\|groups\|url\|SQL\|long). Optional: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Example: [{"display_name": "Risk Score", "attribute_type": "int"}, {"display_name": "Status", "attribute_type": "enum", "enum_values": ["Draft", "Approved", "Rejected"]}] (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### remove_attributes_from_cm_set_tool + +Remove (archive) attributes from an existing custom metadata set. Archived attributes are soft-deleted and their values cleared from all assets. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool remove_attributes_from_cm_set_tool --display-name --attribute-names --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--display-name` | string | yes | Display name of the existing CM set | +| `--attribute-names` | string | yes | Display name(s) of attributes to remove (archive). Archived attributes are soft-deleted: values are cleared from assets and hidden in UI. Example: ["Score", "Reviewed By"] or a JSON string of the list. (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### add_atlan_tags_tool + +Add Atlan tags to one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool add_atlan_tags_tool --updates --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--updates` | string | yes | Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {"guid": "...", "tag_names": ["PII"]}. Batch: [{"guid": "g1", "tag_names": ["PII"]}, ...] (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### remove_atlan_tag_tool + +Remove an Atlan tag from one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool remove_atlan_tag_tool --updates --mode --user-query +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--updates` | string | yes | Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {"guid": "...", "tag_name": "PII"}. Batch: [{"guid": "g1", "tag_name": "PII"}, ...] (JSON string) | +| `--mode` | string | no | ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved. | +| `--user-query` | string | no | REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability. (JSON string) | + +### get_asset_icons + +Fetch SVG icons for asset types. Internal tool used by widgets. + +```bash +uv run --with fastmcp python atlan_cli.py call-tool get_asset_icons --icon-names +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--icon-names` | array[string] | yes | | + +## Utility Commands + +```bash +uv run --with fastmcp python atlan_cli.py list-tools +uv run --with fastmcp python atlan_cli.py list-resources +uv run --with fastmcp python atlan_cli.py read-resource +uv run --with fastmcp python atlan_cli.py list-prompts +uv run --with fastmcp python atlan_cli.py get-prompt [key=value ...] +``` diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py new file mode 100755 index 0000000..474dd77 --- /dev/null +++ b/mcp-cli/atlan_cli.py @@ -0,0 +1,724 @@ +#!/usr/bin/env python3 +"""CLI for mcp.atlan.com MCP server. + +Generated by: fastmcp generate-cli https://mcp.atlan.com/mcp +""" + +import json +import os +import sys +from typing import Annotated + +import cyclopts +import mcp.types +from rich.console import Console + +from fastmcp import Client +from fastmcp.client.auth import BearerAuth, OAuth + +# Auth resolution: +# ATLAN_BASE_URL + ATLAN_API_KEY → Bearer auth against {base_url}/mcp/api-key +# ATLAN_BASE_URL (no API key) → OAuth against {base_url}/mcp +_base_url = os.environ.get("ATLAN_BASE_URL", "").rstrip("/") +_api_key = os.environ.get("ATLAN_API_KEY") + +if not _base_url: + raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") + +if _api_key: + CLIENT_SPEC = f"{_base_url}/mcp/api-key" + _auth = BearerAuth(_api_key) +else: + CLIENT_SPEC = f"{_base_url}/mcp" + _auth = OAuth(mcp_url=CLIENT_SPEC) + +app = cyclopts.App(name="mcp.atlan.com", help="CLI for mcp.atlan.com MCP server") +call_tool_app = cyclopts.App(name="call-tool", help="Call a tool on the server") +app.command(call_tool_app) + +console = Console() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _print_tool_result(result): + if result.is_error: + for block in result.content: + if isinstance(block, mcp.types.TextContent): + console.print(f"[bold red]Error:[/bold red] {block.text}") + else: + console.print(f"[bold red]Error:[/bold red] {block}") + sys.exit(1) + + if result.structured_content is not None: + console.print_json(json.dumps(result.structured_content)) + return + + for block in result.content: + if isinstance(block, mcp.types.TextContent): + console.print(block.text) + elif isinstance(block, mcp.types.ImageContent): + size = len(block.data) * 3 // 4 + console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") + elif isinstance(block, mcp.types.AudioContent): + size = len(block.data) * 3 // 4 + console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") + + +async def _call_tool(tool_name: str, arguments: dict) -> None: + # Filter out None values and empty lists (defaults for optional array params) + filtered = { + k: v + for k, v in arguments.items() + if v is not None and (not isinstance(v, list) or len(v) > 0) + } + async with Client(CLIENT_SPEC, auth=_auth) as client: + result = await client.call_tool(tool_name, filtered, raise_on_error=False) + _print_tool_result(result) + if result.is_error: + sys.exit(1) + + +# --------------------------------------------------------------------------- +# List / read commands +# --------------------------------------------------------------------------- + + +@app.command +async def list_tools() -> None: + """List available tools.""" + async with Client(CLIENT_SPEC, auth=_auth) as client: + tools = await client.list_tools() + if not tools: + console.print("[dim]No tools found.[/dim]") + return + for tool in tools: + sig_parts = [] + props = tool.inputSchema.get("properties", {}) + required = set(tool.inputSchema.get("required", [])) + for pname, pschema in props.items(): + ptype = pschema.get("type", "string") + if pname in required: + sig_parts.append(f"{pname}: {ptype}") + else: + sig_parts.append(f"{pname}: {ptype} = ...") + sig = f"{tool.name}({', '.join(sig_parts)})" + console.print(f" [cyan]{sig}[/cyan]") + if tool.description: + console.print(f" {tool.description}") + console.print() + + +@app.command +async def list_resources() -> None: + """List available resources.""" + async with Client(CLIENT_SPEC, auth=_auth) as client: + resources = await client.list_resources() + if not resources: + console.print("[dim]No resources found.[/dim]") + return + for r in resources: + console.print(f" [cyan]{r.uri}[/cyan]") + desc_parts = [r.name or "", r.description or ""] + desc = " — ".join(p for p in desc_parts if p) + if desc: + console.print(f" {desc}") + console.print() + + +@app.command +async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource URI")]) -> None: + """Read a resource by URI.""" + async with Client(CLIENT_SPEC, auth=_auth) as client: + contents = await client.read_resource(uri) + for block in contents: + if isinstance(block, mcp.types.TextResourceContents): + console.print(block.text) + elif isinstance(block, mcp.types.BlobResourceContents): + size = len(block.blob) * 3 // 4 + console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") + + +@app.command +async def list_prompts() -> None: + """List available prompts.""" + async with Client(CLIENT_SPEC, auth=_auth) as client: + prompts = await client.list_prompts() + if not prompts: + console.print("[dim]No prompts found.[/dim]") + return + for p in prompts: + args_str = "" + if p.arguments: + parts = [a.name for a in p.arguments] + args_str = f"({', '.join(parts)})" + console.print(f" [cyan]{p.name}{args_str}[/cyan]") + if p.description: + console.print(f" {p.description}") + console.print() + + +@app.command +async def get_prompt( + name: Annotated[str, cyclopts.Parameter(help="Prompt name")], + *arguments: str, +) -> None: + """Get a prompt by name. Pass arguments as key=value pairs.""" + parsed: dict[str, str] = {} + for arg in arguments: + if "=" not in arg: + console.print(f"[bold red]Error:[/bold red] Invalid argument {arg!r} — expected key=value") + sys.exit(1) + key, value = arg.split("=", 1) + parsed[key] = value + + async with Client(CLIENT_SPEC, auth=_auth) as client: + result = await client.get_prompt(name, parsed or None) + for msg in result.messages: + console.print(f"[bold]{msg.role}:[/bold]") + if isinstance(msg.content, mcp.types.TextContent): + console.print(f" {msg.content.text}") + elif isinstance(msg.content, mcp.types.ImageContent): + size = len(msg.content.data) * 3 // 4 + console.print(f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]") + else: + console.print(f" {msg.content}") + console.print() + + +# --------------------------------------------------------------------------- +# Tool commands (generated from server schema) +# --------------------------------------------------------------------------- + +@call_tool_app.command(name='semantic_search_tool') +async def semantic_search_tool( + *, + user_query: Annotated[str, cyclopts.Parameter(help="Natural language search query")], + limit: Annotated[str | None, cyclopts.Parameter(help="Max results to return (default 10, max 20)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"integer\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Max results to return (default 10, max 20)\"\n }")] = None, + offset: Annotated[str | None, cyclopts.Parameter(help="Skip first N results for pagination\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"integer\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Skip first N results for pagination\"\n }")] = None, + include_readme: Annotated[bool, cyclopts.Parameter(help="Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency.")] = False, +) -> None: + '''PREFERRED search tool — use this FIRST for any discovery or lookup query. Handles natural language, fuzzy matching, typos, abbreviations, multi-word names, and glossary term lookups. Covers all asset types: tables, columns, views, schemas, dashboards, glossary terms, categories, domains, data products, and more. NOT for users or groups — use resolve_metadata_tool for those. Only fall back to search_assets_tool if you need exact attribute filtering, aggregations, or structured conditions that semantic search cannot express. This tool accepts ONLY: user_query (required), limit, offset, include_readme. It does NOT accept asset_type, conditions, search_query, or any other parameters.''' + # Parse JSON parameters + limit_parsed = json.loads(limit) if isinstance(limit, str) else limit + offset_parsed = json.loads(offset) if isinstance(offset, str) else offset + + await _call_tool('semantic_search_tool', {'user_query': user_query, 'limit': limit_parsed, 'offset': offset_parsed, 'include_readme': include_readme}) + + +@call_tool_app.command(name='query_deep_sql_tool') +async def query_deep_sql_tool( + *, + sql: Annotated[str, cyclopts.Parameter(help="SELECT SQL query to execute against the Gold Layer.")], + limit: Annotated[int, cyclopts.Parameter(help="Max rows to return.")] = 50, + offset: Annotated[int, cyclopts.Parameter(help="Row offset for pagination.")] = 0, +) -> None: + '''Execute Gold Layer SQL for pagination in the deep query results widget. Internal tool.''' + await _call_tool('query_deep_sql_tool', {'sql': sql, 'limit': limit, 'offset': offset}) + + +@call_tool_app.command(name='search_assets_tool') +async def search_assets_tool( + *, + conditions: Annotated[str | None, cyclopts.Parameter(help="Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {\"__categories\": \"\"} (direct only) or {\"__categories\": {\"operator\": \"within\", \"value\": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {\\\"__categories\\\": \\\"\\\"} (direct only) or {\\\"__categories\\\": {\\\"operator\\\": \\\"within\\\", \\\"value\\\": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.\",\n \"examples\": [\n {\n \"name\": \"customers\"\n },\n {\n \"name\": {\n \"operator\": \"startswith\",\n \"value\": \"dim_\"\n }\n },\n {\n \"certificate_status\": \"VERIFIED\"\n },\n {\n \"__typeName\": \"Table\"\n },\n {\n \"__categories\": \"\"\n },\n {\n \"__categories\": {\n \"operator\": \"within\",\n \"value\": [\n \"\",\n \"\",\n \"\"\n ]\n }\n }\n ]\n }")] = None, + negative_conditions: Annotated[str | None, cyclopts.Parameter(help="Exclusion filters (same format as conditions)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Exclusion filters (same format as conditions)\",\n \"examples\": [\n {\n \"certificate_status\": \"DEPRECATED\"\n },\n {\n \"name\": {\n \"operator\": \"contains\",\n \"value\": \"test\"\n }\n }\n ]\n }")] = None, + some_conditions: Annotated[str | None, cyclopts.Parameter(help="OR-style filters requiring min_somes matches\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"OR-style filters requiring min_somes matches\",\n \"examples\": [\n {\n \"owner_groups\": \"admins\",\n \"owner_users\": \"user1\"\n }\n ]\n }")] = None, + min_somes: Annotated[int, cyclopts.Parameter(help="Minimum some_conditions to match")] = 1, + include_attributes: Annotated[str | None, cyclopts.Parameter(help="Attributes to return (e.g., owner_users, columns, readme). For category hierarchy traversal include parentCategory and qualifiedName.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Attributes to return (e.g., owner_users, columns, readme). For category hierarchy traversal include parentCategory and qualifiedName.\"\n }")] = None, + asset_type: Annotated[str | None, cyclopts.Parameter(help="Asset type(s) to search. Single type or list of types.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"enum\": [\n \"Table\",\n \"Column\",\n \"View\",\n \"Database\",\n \"Schema\",\n \"MaterializedView\",\n \"AtlasGlossary\",\n \"AtlasGlossaryTerm\",\n \"AtlasGlossaryCategory\",\n \"Connection\",\n \"Process\",\n \"Query\",\n \"Dashboard\",\n \"Report\",\n \"DataDomain\",\n \"DataProduct\",\n \"PowerBIReport\",\n \"PowerBIDataset\",\n \"PowerBIDashboard\",\n \"PowerBIWorkspace\",\n \"PowerBIDataflow\",\n \"PowerBITable\",\n \"PowerBIMeasure\",\n \"PowerBIColumn\",\n \"PowerBIPage\",\n \"DbtModel\",\n \"DbtTest\",\n \"DbtSource\",\n \"DbtMetric\",\n \"DbtModelColumn\",\n \"TableauDashboard\",\n \"TableauCalculatedField\",\n \"TableauWorkbook\",\n \"TableauDatasource\",\n \"TableauProject\",\n \"TableauWorksheet\",\n \"TableauSite\",\n \"TableauFlow\",\n \"LookerDashboard\",\n \"LookerExplore\",\n \"LookerLook\",\n \"LookerView\",\n \"LookerModel\",\n \"LookerProject\",\n \"LookerQuery\",\n \"LookerFolder\",\n \"LookerTile\",\n \"LookerField\",\n \"SigmaWorkbook\",\n \"SigmaDataset\",\n \"SigmaDataElement\",\n \"SigmaPage\",\n \"ThoughtspotLiveboard\",\n \"ThoughtspotAnswer\",\n \"ThoughtspotWorksheet\",\n \"MetabaseDashboard\",\n \"MetabaseQuestion\",\n \"MetabaseCollection\",\n \"QuickSightDashboard\",\n \"QuickSightDataset\",\n \"QuickSightAnalysis\",\n \"PresetDashboard\",\n \"PresetChart\",\n \"PresetDataset\",\n \"MicroStrategyDossier\",\n \"MicroStrategyReport\",\n \"ModeReport\",\n \"ModeQuery\",\n \"ModeWorkspace\",\n \"DomoDashboard\",\n \"DomoDataset\",\n \"DomoCard\",\n \"SisenseDashboard\",\n \"SisenseDatamodel\",\n \"RedashDashboard\",\n \"RedashQuery\",\n \"QlikApp\",\n \"QlikDataset\",\n \"QlikSheet\",\n \"QlikSpace\"\n ],\n \"type\": \"string\"\n },\n {\n \"items\": {\n \"enum\": [\n \"Table\",\n \"Column\",\n \"View\",\n \"Database\",\n \"Schema\",\n \"MaterializedView\",\n \"AtlasGlossary\",\n \"AtlasGlossaryTerm\",\n \"AtlasGlossaryCategory\",\n \"Connection\",\n \"Process\",\n \"Query\",\n \"Dashboard\",\n \"Report\",\n \"DataDomain\",\n \"DataProduct\",\n \"PowerBIReport\",\n \"PowerBIDataset\",\n \"PowerBIDashboard\",\n \"PowerBIWorkspace\",\n \"PowerBIDataflow\",\n \"PowerBITable\",\n \"PowerBIMeasure\",\n \"PowerBIColumn\",\n \"PowerBIPage\",\n \"DbtModel\",\n \"DbtTest\",\n \"DbtSource\",\n \"DbtMetric\",\n \"DbtModelColumn\",\n \"TableauDashboard\",\n \"TableauCalculatedField\",\n \"TableauWorkbook\",\n \"TableauDatasource\",\n \"TableauProject\",\n \"TableauWorksheet\",\n \"TableauSite\",\n \"TableauFlow\",\n \"LookerDashboard\",\n \"LookerExplore\",\n \"LookerLook\",\n \"LookerView\",\n \"LookerModel\",\n \"LookerProject\",\n \"LookerQuery\",\n \"LookerFolder\",\n \"LookerTile\",\n \"LookerField\",\n \"SigmaWorkbook\",\n \"SigmaDataset\",\n \"SigmaDataElement\",\n \"SigmaPage\",\n \"ThoughtspotLiveboard\",\n \"ThoughtspotAnswer\",\n \"ThoughtspotWorksheet\",\n \"MetabaseDashboard\",\n \"MetabaseQuestion\",\n \"MetabaseCollection\",\n \"QuickSightDashboard\",\n \"QuickSightDataset\",\n \"QuickSightAnalysis\",\n \"PresetDashboard\",\n \"PresetChart\",\n \"PresetDataset\",\n \"MicroStrategyDossier\",\n \"MicroStrategyReport\",\n \"ModeReport\",\n \"ModeQuery\",\n \"ModeWorkspace\",\n \"DomoDashboard\",\n \"DomoDataset\",\n \"DomoCard\",\n \"SisenseDashboard\",\n \"SisenseDatamodel\",\n \"RedashDashboard\",\n \"RedashQuery\",\n \"QlikApp\",\n \"QlikDataset\",\n \"QlikSheet\",\n \"QlikSpace\"\n ],\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset type(s) to search. Single type or list of types.\"\n }")] = None, + glossary_qualified_name: Annotated[str | None, cyclopts.Parameter(help="Glossary qualifiedName to scope search to terms/categories within that glossary\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Glossary qualifiedName to scope search to terms/categories within that glossary\"\n }")] = None, + include_archived: Annotated[bool, cyclopts.Parameter(help="Include archived/deleted assets")] = False, + limit: Annotated[int, cyclopts.Parameter(help="Max results to return (default 10, max 20)")] = 10, + offset: Annotated[int, cyclopts.Parameter(help="Pagination offset")] = 0, + sort_by: Annotated[str | None, cyclopts.Parameter(help="Single field to sort by\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Single field to sort by\"\n }")] = None, + sort_order: Annotated[str, cyclopts.Parameter(help="Sort order")] = 'ASC', + sort: Annotated[str | None, cyclopts.Parameter(help="Multi-field sort\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Multi-field sort\",\n \"examples\": [\n [\n {\n \"field\": \"name\",\n \"order\": \"ASC\"\n }\n ],\n [\n {\n \"field\": \"createTime\",\n \"order\": \"DESC\"\n },\n {\n \"field\": \"name\",\n \"order\": \"ASC\"\n }\n ]\n ]\n }")] = None, + connection_qualified_name: Annotated[str | None, cyclopts.Parameter(help="Filter by connection (e.g., default/snowflake/123)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by connection (e.g., default/snowflake/123)\"\n }")] = None, + tags: Annotated[str | None, cyclopts.Parameter(help="Filter by Atlan tag names\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by Atlan tag names\"\n }")] = None, + directly_tagged: Annotated[bool, cyclopts.Parameter(help="Only directly tagged (not inherited)")] = True, + domain_guids: Annotated[str | None, cyclopts.Parameter(help="Filter by domain GUIDs. For DataProduct/DataDomain asset types, this recursively includes all sub-domains — e.g. passing a parent domain GUID returns products under that domain AND all its sub-domains at any depth.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by domain GUIDs. For DataProduct/DataDomain asset types, this recursively includes all sub-domains — e.g. passing a parent domain GUID returns products under that domain AND all its sub-domains at any depth.\"\n }")] = None, + date_range: Annotated[str | None, cyclopts.Parameter(help="Date filters\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Date filters\",\n \"examples\": [\n {\n \"createTime\": {\n \"gte\": 1704067200000\n }\n },\n {\n \"updateTime\": {\n \"gte\": 1704067200000,\n \"lte\": 1706745600000\n }\n }\n ]\n }")] = None, + guids: Annotated[str | None, cyclopts.Parameter(help="Filter by specific asset GUIDs\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by specific asset GUIDs\"\n }")] = None, + term_guids: Annotated[str | None, cyclopts.Parameter(help="Filter by assigned glossary term GUIDs\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by assigned glossary term GUIDs\"\n }")] = None, + aggregations: Annotated[str | None, cyclopts.Parameter(help="Aggregations to compute. Use __meanings aggregation to find which glossary terms are linked to assets.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Aggregations to compute. Use __meanings aggregation to find which glossary terms are linked to assets.\",\n \"examples\": [\n {\n \"by_type\": {\n \"field\": \"__typeName\",\n \"size\": 10,\n \"type\": \"terms\"\n }\n },\n {\n \"by_owner\": {\n \"field\": \"ownerUsers\",\n \"size\": 20,\n \"type\": \"terms\"\n }\n },\n {\n \"linked_terms\": {\n \"field\": \"__meanings\",\n \"size\": 500\n }\n }\n ]\n }")] = None, + count_only: Annotated[bool, cyclopts.Parameter(help="Return only count, no results")] = False, + scroll: Annotated[bool, cyclopts.Parameter(help="Enable scroll mode for >10k results")] = False, + include_readme: Annotated[bool, cyclopts.Parameter(help="Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency.")] = False, + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''BACKUP search tool — only use when the primary search tools (start_deep_query_tool or semantic_search_tool) cannot express what you need. Provides structured asset search with exact filters, conditions, aggregations, sorting, and pagination. Use this ONLY for precise attribute filters (e.g. certificateStatus, connectorName), term_guids, tags, domain_guids, count_only, or aggregations that the primary tools don\'t support. + +LINKED/UNLINKED TERMS — MANDATORY 2-CALL FLOW: +When the user asks about linked, unlinked, orphan, or unused glossary terms you MUST make TWO separate calls: +Call 1: Get terms — search_assets(asset_type="AtlasGlossaryTerm", glossary_qualified_name="", include_attributes=["qualifiedName","name"], limit=100) +Call 2: Get linked term QNs — search_assets(aggregations={"linked_terms": {"field": "__meanings", "size": 500}}, limit=1) — NO asset_type, NO glossary filter. This aggregates __meanings across ALL assets in the catalog. +Then DIFF: terms from Call 1 whose qualifiedName appears in Call 2\'s aggregation buckets are LINKED. Terms absent are UNLINKED. +IMPORTANT: Call 2 must NOT have asset_type or glossary_qualified_name filters — __meanings lives on regular assets (Tables, Columns), not on terms. Scoping Call 2 to terms returns empty buckets. + +GLOSSARY TERM WORKFLOWS: +- Filter terms by category: conditions={"__categories": ""}. NOTE: __categories only stores the DIRECT parent category. To include subcategories, first fetch all categories in the glossary (asset_type="AtlasGlossaryCategory", include_attributes=["qualifiedName","parentCategory"]), walk the parentCategory tree to find all descendants, then use conditions={"__categories": {"operator": "within", "value": [allDescendantQNs]}}. +NOT for users/groups — use resolve_metadata_tool for those.''' + # Parse JSON parameters + conditions_parsed = json.loads(conditions) if isinstance(conditions, str) else conditions + negative_conditions_parsed = json.loads(negative_conditions) if isinstance(negative_conditions, str) else negative_conditions + some_conditions_parsed = json.loads(some_conditions) if isinstance(some_conditions, str) else some_conditions + include_attributes_parsed = json.loads(include_attributes) if isinstance(include_attributes, str) else include_attributes + asset_type_parsed = json.loads(asset_type) if isinstance(asset_type, str) else asset_type + glossary_qualified_name_parsed = json.loads(glossary_qualified_name) if isinstance(glossary_qualified_name, str) else glossary_qualified_name + sort_by_parsed = json.loads(sort_by) if isinstance(sort_by, str) else sort_by + sort_parsed = json.loads(sort) if isinstance(sort, str) else sort + connection_qualified_name_parsed = json.loads(connection_qualified_name) if isinstance(connection_qualified_name, str) else connection_qualified_name + tags_parsed = json.loads(tags) if isinstance(tags, str) else tags + domain_guids_parsed = json.loads(domain_guids) if isinstance(domain_guids, str) else domain_guids + date_range_parsed = json.loads(date_range) if isinstance(date_range, str) else date_range + guids_parsed = json.loads(guids) if isinstance(guids, str) else guids + term_guids_parsed = json.loads(term_guids) if isinstance(term_guids, str) else term_guids + aggregations_parsed = json.loads(aggregations) if isinstance(aggregations, str) else aggregations + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('search_assets_tool', {'conditions': conditions_parsed, 'negative_conditions': negative_conditions_parsed, 'some_conditions': some_conditions_parsed, 'min_somes': min_somes, 'include_attributes': include_attributes_parsed, 'asset_type': asset_type_parsed, 'glossary_qualified_name': glossary_qualified_name_parsed, 'include_archived': include_archived, 'limit': limit, 'offset': offset, 'sort_by': sort_by_parsed, 'sort_order': sort_order, 'sort': sort_parsed, 'connection_qualified_name': connection_qualified_name_parsed, 'tags': tags_parsed, 'directly_tagged': directly_tagged, 'domain_guids': domain_guids_parsed, 'date_range': date_range_parsed, 'guids': guids_parsed, 'term_guids': term_guids_parsed, 'aggregations': aggregations_parsed, 'count_only': count_only, 'scroll': scroll, 'include_readme': include_readme, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='traverse_lineage_tool') +async def traverse_lineage_tool( + *, + guid: Annotated[str, cyclopts.Parameter(help="GUID of the starting asset")], + direction: Annotated[str, cyclopts.Parameter(help="Lineage traversal direction")], + depth: Annotated[int, cyclopts.Parameter(help="Maximum depth to traverse")] = 1000000, + size: Annotated[int, cyclopts.Parameter(help="Maximum number of data assets to show in the lineage graph. The widget supports progressive loading — users can click expand on leaf nodes to load more. Keep this small (5-15). Max 20.")] = 10, + immediate_neighbors: Annotated[bool, cyclopts.Parameter(help="Only return immediate neighbors (one hop)")] = True, + offset: Annotated[int, cyclopts.Parameter(help="Pagination offset for relations (default 0)")] = 0, + include_attributes: Annotated[str | None, cyclopts.Parameter(help="Additional attributes to include\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Additional attributes to include\"\n }")] = None, + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Traverse upstream or downstream lineage from an asset. + +Returns a widget-ready graph with data assets connected by lineage edges. +Process connector nodes are bridged in the graph (A -> B instead of +A -> Process -> B); their entity data is exposed in the ``process_map`` +field and each relation carries ``via_process_guids`` identifying which +ETL/transformation processes produced the connection. The lineage widget +supports progressive loading — leaf nodes show an expand button that +fetches deeper lineage on demand. Keep size small (5-15) for responsive +results; do NOT request all lineage at once.''' + # Parse JSON parameters + include_attributes_parsed = json.loads(include_attributes) if isinstance(include_attributes, str) else include_attributes + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('traverse_lineage_tool', {'guid': guid, 'direction': direction, 'depth': depth, 'size': size, 'immediate_neighbors': immediate_neighbors, 'offset': offset, 'include_attributes': include_attributes_parsed, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='query_assets_tool') +async def query_assets_tool( + *, + sql: Annotated[str, cyclopts.Parameter(help="SQL query to execute against the connection")], + connection_qualified_name: Annotated[str, cyclopts.Parameter(help="Qualified name of the connection to query")], + default_schema: Annotated[str | None, cyclopts.Parameter(help="Default schema for unqualified table references\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Default schema for unqualified table references\"\n }")] = None, + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Execute SQL queries against Atlan connections to preview data.''' + # Parse JSON parameters + default_schema_parsed = json.loads(default_schema) if isinstance(default_schema, str) else default_schema + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('query_assets_tool', {'sql': sql, 'connection_qualified_name': connection_qualified_name, 'default_schema': default_schema_parsed, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='resolve_metadata_tool') +async def resolve_metadata_tool( + *, + namespace_type: Annotated[str, cyclopts.Parameter(help="Metadata namespace to search. Use 'data_domain_and_product' for BOTH data domains AND data products.")], + query: Annotated[str, cyclopts.Parameter(help="Search query - name, description, or natural language")], + limit: Annotated[int, cyclopts.Parameter(help="Max results (default 10, max 20)")] = 10, + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Use this to search for users and groups, and to get exact usernames and group names before making updates. Also use for extra discovery on classifications, business_metadata, glossary, and data_domain_and_product when semantic_search doesn\'t return needed results, or before write operations to confirm exact names and GUIDs.''' + # Parse JSON parameters + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('resolve_metadata_tool', {'namespace_type': namespace_type, 'query': query, 'limit': limit, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='search_atlan_docs_tool') +async def search_atlan_docs_tool( + *, + query: Annotated[str, cyclopts.Parameter(help="Question about Atlan features, how-tos, or configuration. E.g. 'How do I connect Snowflake to Atlan?' or 'How do I create a data product?'")], + top_k: Annotated[int, cyclopts.Parameter(help="Number of documentation sources to retrieve (1-10)")] = 5, + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Search Atlan\'s customer-facing documentation and return an LLM-generated answer with source citations. Use for how-to questions about Atlan features — not for searching data assets (use semantic_search_tool for that).''' + # Parse JSON parameters + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('search_atlan_docs_tool', {'query': query, 'top_k': top_k, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='get_groups_tool') +async def get_groups_tool( + *, + name_filter: Annotated[str | None, cyclopts.Parameter(help="Filter by name (partial match)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by name (partial match)\"\n }")] = None, + group_id: Annotated[str | None, cyclopts.Parameter(help="Get specific group by ID (GUID)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Get specific group by ID (GUID)\"\n }")] = None, + include_members: Annotated[bool, cyclopts.Parameter(help="Include group members in response")] = False, + limit: Annotated[int, cyclopts.Parameter(help="Maximum results (1-250)")] = 50, + offset: Annotated[int, cyclopts.Parameter(help="Pagination offset")] = 0, + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Get workspace groups and their members. Use include_members=True to list users in a group.''' + # Parse JSON parameters + name_filter_parsed = json.loads(name_filter) if isinstance(name_filter, str) else name_filter + group_id_parsed = json.loads(group_id) if isinstance(group_id, str) else group_id + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('get_groups_tool', {'name_filter': name_filter_parsed, 'group_id': group_id_parsed, 'include_members': include_members, 'limit': limit, 'offset': offset, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='get_asset_tool') +async def get_asset_tool( + *, + guid: Annotated[str | None, cyclopts.Parameter(help="Asset GUID to retrieve\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset GUID to retrieve\"\n }")] = None, + qualified_name: Annotated[str | None, cyclopts.Parameter(help="Asset qualified name to retrieve (e.g. default/snowflake/1234/DB/SCHEMA/TABLE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset qualified name to retrieve (e.g. default/snowflake/1234/DB/SCHEMA/TABLE)\"\n }")] = None, + asset_type: Annotated[str | None, cyclopts.Parameter(help="Asset type (required when using qualified_name). e.g. Table, Column, View, Database, Schema\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset type (required when using qualified_name). e.g. Table, Column, View, Database, Schema\"\n }")] = None, + include_attributes: Annotated[str | None, cyclopts.Parameter(help="Additional attributes to include\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Additional attributes to include\"\n }")] = None, + include_dq_checks: Annotated[bool, cyclopts.Parameter(help="Set true to include linked data quality checks (Soda, Anomalo, Monte Carlo, Atlan native DQ rules) for this asset")] = False, + include_readme: Annotated[bool, cyclopts.Parameter(help="Set true to fetch and return README content for the asset. Only enable when the user explicitly asks for README/documentation content — adds latency.")] = False, + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Get detailed information about a single asset by its GUID or qualified name.''' + # Parse JSON parameters + guid_parsed = json.loads(guid) if isinstance(guid, str) else guid + qualified_name_parsed = json.loads(qualified_name) if isinstance(qualified_name, str) else qualified_name + asset_type_parsed = json.loads(asset_type) if isinstance(asset_type, str) else asset_type + include_attributes_parsed = json.loads(include_attributes) if isinstance(include_attributes, str) else include_attributes + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('get_asset_tool', {'guid': guid_parsed, 'qualified_name': qualified_name_parsed, 'asset_type': asset_type_parsed, 'include_attributes': include_attributes_parsed, 'include_dq_checks': include_dq_checks, 'include_readme': include_readme, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='update_assets_tool') +async def update_assets_tool( + *, + updates: Annotated[str, cyclopts.Parameter(help="Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly 'append' (link terms), 'remove' (unlink terms), or 'replace' (overwrite all). Do NOT use 'add' or 'delete' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is 'append', 'replace', or 'remove' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly 'append' (link terms), 'remove' (unlink terms), or 'replace' (overwrite all). Do NOT use 'add' or 'delete' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is 'append', 'replace', or 'remove' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.\",\n \"examples\": [\n {\n \"certificate_status\": \"VERIFIED\",\n \"guid\": \"abc-123\",\n \"name\": \"customers\",\n \"qualified_name\": \"default/snowflake/db/schema/customers\",\n \"type_name\": \"Table\",\n \"user_description\": \"Customer master data\"\n },\n [\n {\n \"guid\": \"abc-123\",\n \"name\": \"t1\",\n \"owner_users\": [\n \"user1\",\n \"user2\"\n ],\n \"qualified_name\": \"qn1\",\n \"type_name\": \"Table\"\n },\n {\n \"guid\": \"def-456\",\n \"name\": \"t2\",\n \"qualified_name\": \"qn2\",\n \"term\": {\n \"operation\": \"append\",\n \"term_guids\": [\n \"guid1\"\n ]\n },\n \"type_name\": \"Table\"\n }\n ],\n [\n {\n \"certificate_status\": \"DRAFT\",\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-456\",\n \"name\": \"Churn Rate\",\n \"qualified_name\": \"abc@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\"\n },\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-789\",\n \"name\": \"Revenue\",\n \"qualified_name\": \"def@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\",\n \"user_description\": \"Total income\"\n }\n ],\n {\n \"category_guids\": [\n \"cat-guid-1\",\n \"cat-guid-2\"\n ],\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-101\",\n \"name\": \"Net Revenue\",\n \"qualified_name\": \"ghi@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\"\n },\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-202\",\n \"name\": \"Revenue\",\n \"qualified_name\": \"jkl@glossary-qn\",\n \"term_relations\": {\n \"antonyms\": {\n \"guids\": [\n \"guid-of-cost\"\n ],\n \"op\": \"append\"\n },\n \"synonyms\": {\n \"guids\": [\n \"guid-of-income\",\n \"guid-of-earnings\"\n ],\n \"op\": \"append\"\n }\n },\n \"type_name\": \"AtlasGlossaryTerm\"\n }\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Update attributes on one or more assets. Each item specifies the asset identity + fields to change. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + updates_parsed = json.loads(updates) if isinstance(updates, str) else updates + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('update_assets_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='create_glossaries') +async def create_glossaries( + *, + glossaries: Annotated[str, cyclopts.Parameter(help="Glossary definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Glossary definitions\",\n \"examples\": [\n {\n \"name\": \"Business Glossary\",\n \"user_description\": \"Company-wide terms\"\n },\n [\n {\n \"name\": \"Glossary A\"\n },\n {\n \"certificate_status\": \"VERIFIED\",\n \"name\": \"Glossary B\"\n }\n ]\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Create one or more glossaries in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + glossaries_parsed = json.loads(glossaries) if isinstance(glossaries, str) else glossaries + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('create_glossaries', {'glossaries': glossaries_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='create_glossary_terms') +async def create_glossary_terms( + *, + terms: Annotated[str, cyclopts.Parameter(help="Term definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Term definitions\",\n \"examples\": [\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"name\": \"Revenue\",\n \"user_description\": \"Total income\"\n },\n [\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Term A\"\n },\n {\n \"category_guids\": [\n \"cat-1\"\n ],\n \"glossary_guid\": \"g-123\",\n \"name\": \"Term B\"\n }\n ]\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Create one or more glossary terms in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + terms_parsed = json.loads(terms) if isinstance(terms, str) else terms + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('create_glossary_terms', {'terms': terms_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='create_glossary_categories') +async def create_glossary_categories( + *, + categories: Annotated[str, cyclopts.Parameter(help="Category definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Category definitions\",\n \"examples\": [\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"name\": \"Finance Terms\"\n },\n [\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Cat A\"\n },\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Cat B\",\n \"parent_category_guid\": \"cat-a-guid\"\n }\n ]\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Create one or more glossary categories in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + categories_parsed = json.loads(categories) if isinstance(categories, str) else categories + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('create_glossary_categories', {'categories': categories_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='create_domains') +async def create_domains( + *, + domains: Annotated[str, cyclopts.Parameter(help="Domain definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Domain definitions\",\n \"examples\": [\n {\n \"name\": \"Sales Domain\",\n \"user_description\": \"Sales analytics data\"\n },\n [\n {\n \"name\": \"Finance\"\n },\n {\n \"name\": \"Marketing\",\n \"parent_domain_qualified_name\": \"default/domain/Finance\"\n }\n ]\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Create one or more data domains in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + domains_parsed = json.loads(domains) if isinstance(domains, str) else domains + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('create_domains', {'domains': domains_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='create_data_products') +async def create_data_products( + *, + products: Annotated[str, cyclopts.Parameter(help="Product definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Product definitions\",\n \"examples\": [\n {\n \"asset_guids\": [\n \"asset-guid-1\"\n ],\n \"domain_qualified_name\": \"default/domain/Sales\",\n \"name\": \"Customer 360\"\n },\n [\n {\n \"asset_guids\": [\n \"g1\",\n \"g2\"\n ],\n \"domain_qualified_name\": \"default/domain/D1\",\n \"name\": \"Prod A\",\n \"user_description\": \"Product A\"\n }\n ]\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Create one or more data products in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + products_parsed = json.loads(products) if isinstance(products, str) else products + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('create_data_products', {'products': products_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='create_dq_rules_tool') +async def create_dq_rules_tool( + *, + rules: Annotated[str, cyclopts.Parameter(help="DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is 'Custom SQL'. rule_name (str): display name, required when rule_type is 'Custom SQL'. dimension (str): required when rule_type is 'Custom SQL': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is 'Custom SQL'. rule_name (str): display name, required when rule_type is 'Custom SQL'. dimension (str): required when rule_type is 'Custom SQL': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.\",\n \"examples\": [\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/table/column\",\n \"rule_type\": \"Null Count\",\n \"threshold_compare_operator\": \"EQ\",\n \"threshold_value\": 0\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"rule_type\": \"Row Count\",\n \"threshold_compare_operator\": \"GTE\",\n \"threshold_value\": 100\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"custom_sql\": \"SELECT COUNT(*) FROM table\",\n \"dimension\": \"COMPLETENESS\",\n \"rule_name\": \"MyRule\",\n \"rule_type\": \"Custom SQL\",\n \"threshold_compare_operator\": \"EQ\",\n \"threshold_value\": 0\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/source_table\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/target_table\",\n \"rule_type\": \"Recon Row Count\",\n \"threshold_unit\": \"PERCENTAGE\",\n \"threshold_value\": 3\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/source_table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/source_table/col\",\n \"reference_column_qualified_name\": \"default/snowflake/db/schema/target_table/col\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/target_table\",\n \"rule_type\": \"Recon Unique Count\",\n \"threshold_unit\": \"PERCENTAGE\",\n \"threshold_value\": 2\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/table/country\",\n \"reference_column_qualified_name\": \"default/snowflake/db/schema/valid_countries/code\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/valid_countries\",\n \"rule_type\": \"Valid Values Reference\",\n \"threshold_value\": 2\n }\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Create data quality rules in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + rules_parsed = json.loads(rules) if isinstance(rules, str) else rules + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('create_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='schedule_dq_rules_tool') +async def schedule_dq_rules_tool( + *, + schedules: Annotated[str, cyclopts.Parameter(help="Schedule definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Schedule definitions\",\n \"examples\": [\n {\n \"asset_name\": \"customers\",\n \"asset_qualified_name\": \"default/snowflake/db/schema/customers\",\n \"asset_type\": \"Table\",\n \"schedule_crontab\": \"0 6 * * *\",\n \"schedule_time_zone\": \"America/New_York\"\n },\n [\n {\n \"asset_name\": \"v1\",\n \"asset_qualified_name\": \"qn1\",\n \"asset_type\": \"View\",\n \"schedule_crontab\": \"0 */4 * * *\",\n \"schedule_time_zone\": \"UTC\"\n }\n ]\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Schedule data quality rule execution on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + schedules_parsed = json.loads(schedules) if isinstance(schedules, str) else schedules + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('schedule_dq_rules_tool', {'schedules': schedules_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='update_dq_rules_tool') +async def update_dq_rules_tool( + *, + rules: Annotated[str, cyclopts.Parameter(help="DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.\",\n \"examples\": [\n {\n \"alert_priority\": \"URGENT\",\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"qualified_name\": \"default/dq/rule-qn\",\n \"rule_type\": \"Null Count\",\n \"threshold_value\": 5\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"qualified_name\": \"default/dq/rule-qn\",\n \"rule_type\": \"Freshness\",\n \"threshold_unit\": \"HOURS\",\n \"threshold_value\": 24\n }\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Update existing data quality rules. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + rules_parsed = json.loads(rules) if isinstance(rules, str) else rules + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('update_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='delete_dq_rules_tool') +async def delete_dq_rules_tool( + *, + rule_guids: Annotated[str, cyclopts.Parameter(help="GUIDs of DQ rules to delete\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"GUIDs of DQ rules to delete\"\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Delete data quality rules by their GUIDs. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + rule_guids_parsed = json.loads(rule_guids) if isinstance(rule_guids, str) else rule_guids + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('delete_dq_rules_tool', {'rule_guids': rule_guids_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='manage_asset_lifecycle_tool') +async def manage_asset_lifecycle_tool( + *, + operation: Annotated[str, cyclopts.Parameter(help="Lifecycle operation to perform")], + guids: Annotated[str | None, cyclopts.Parameter(help="Asset GUIDs (required for ARCHIVE/PURGE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset GUIDs (required for ARCHIVE/PURGE)\"\n }")] = None, + asset_type: Annotated[str | None, cyclopts.Parameter(help="Asset type (required for RESTORE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"enum\": [\n \"Table\",\n \"Column\",\n \"View\",\n \"Database\",\n \"Schema\",\n \"MaterializedView\",\n \"AtlasGlossary\",\n \"AtlasGlossaryTerm\",\n \"AtlasGlossaryCategory\",\n \"Connection\",\n \"Process\",\n \"Query\",\n \"Dashboard\",\n \"Report\",\n \"DataDomain\",\n \"DataProduct\",\n \"PowerBIReport\",\n \"PowerBIDataset\",\n \"PowerBIDashboard\",\n \"PowerBIWorkspace\",\n \"PowerBIDataflow\",\n \"PowerBITable\",\n \"PowerBIMeasure\",\n \"PowerBIColumn\",\n \"PowerBIPage\",\n \"DbtModel\",\n \"DbtTest\",\n \"DbtSource\",\n \"DbtMetric\",\n \"DbtModelColumn\",\n \"TableauDashboard\",\n \"TableauCalculatedField\",\n \"TableauWorkbook\",\n \"TableauDatasource\",\n \"TableauProject\",\n \"TableauWorksheet\",\n \"TableauSite\",\n \"TableauFlow\",\n \"LookerDashboard\",\n \"LookerExplore\",\n \"LookerLook\",\n \"LookerView\",\n \"LookerModel\",\n \"LookerProject\",\n \"LookerQuery\",\n \"LookerFolder\",\n \"LookerTile\",\n \"LookerField\",\n \"SigmaWorkbook\",\n \"SigmaDataset\",\n \"SigmaDataElement\",\n \"SigmaPage\",\n \"ThoughtspotLiveboard\",\n \"ThoughtspotAnswer\",\n \"ThoughtspotWorksheet\",\n \"MetabaseDashboard\",\n \"MetabaseQuestion\",\n \"MetabaseCollection\",\n \"QuickSightDashboard\",\n \"QuickSightDataset\",\n \"QuickSightAnalysis\",\n \"PresetDashboard\",\n \"PresetChart\",\n \"PresetDataset\",\n \"MicroStrategyDossier\",\n \"MicroStrategyReport\",\n \"ModeReport\",\n \"ModeQuery\",\n \"ModeWorkspace\",\n \"DomoDashboard\",\n \"DomoDataset\",\n \"DomoCard\",\n \"SisenseDashboard\",\n \"SisenseDatamodel\",\n \"RedashDashboard\",\n \"RedashQuery\",\n \"QlikApp\",\n \"QlikDataset\",\n \"QlikSheet\",\n \"QlikSpace\"\n ],\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset type (required for RESTORE)\"\n }")] = None, + qualified_name: Annotated[str | None, cyclopts.Parameter(help="Qualified name (required for RESTORE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Qualified name (required for RESTORE)\"\n }")] = None, + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Manage asset lifecycle: archive, restore, or permanently purge assets. WARNING: PURGE cannot be undone. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + guids_parsed = json.loads(guids) if isinstance(guids, str) else guids + asset_type_parsed = json.loads(asset_type) if isinstance(asset_type, str) else asset_type + qualified_name_parsed = json.loads(qualified_name) if isinstance(qualified_name, str) else qualified_name + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('manage_asset_lifecycle_tool', {'operation': operation, 'guids': guids_parsed, 'asset_type': asset_type_parsed, 'qualified_name': qualified_name_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='manage_announcements_tool') +async def manage_announcements_tool( + *, + asset_guids: Annotated[str, cyclopts.Parameter(help="Asset GUIDs to update (comma-separated or JSON array)")], + operation: Annotated[str, cyclopts.Parameter(help="Announcement operation")], + announcement_type: Annotated[str | None, cyclopts.Parameter(help="Announcement type (required for SET)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"enum\": [\n \"information\",\n \"warning\",\n \"issue\"\n ],\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Announcement type (required for SET)\"\n }")] = None, + title: Annotated[str | None, cyclopts.Parameter(help="Announcement title (required for SET)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Announcement title (required for SET)\"\n }")] = None, + message: Annotated[str | None, cyclopts.Parameter(help="Announcement message (optional)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Announcement message (optional)\"\n }")] = None, + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Add or remove announcements (information, warning, issue) on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + announcement_type_parsed = json.loads(announcement_type) if isinstance(announcement_type, str) else announcement_type + title_parsed = json.loads(title) if isinstance(title, str) else title + message_parsed = json.loads(message) if isinstance(message, str) else message + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('manage_announcements_tool', {'asset_guids': asset_guids, 'operation': operation, 'announcement_type': announcement_type_parsed, 'title': title_parsed, 'message': message_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='update_custom_metadata_tool') +async def update_custom_metadata_tool( + *, + updates: Annotated[str, cyclopts.Parameter(help="Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {\"guid\": \"...\", \"custom_metadata_name\": \"CM\", \"attributes\": {\"k\": \"v\"}}. Batch: [{\"guid\": \"g1\", ...}, {\"guid\": \"g2\", ...}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {\\\"guid\\\": \\\"...\\\", \\\"custom_metadata_name\\\": \\\"CM\\\", \\\"attributes\\\": {\\\"k\\\": \\\"v\\\"}}. Batch: [{\\\"guid\\\": \\\"g1\\\", ...}, {\\\"guid\\\": \\\"g2\\\", ...}]\"\n }")], + replace: Annotated[bool, cyclopts.Parameter(help="If False (default): partial update — only specified attributes are changed. If True: full replacement — ALL attributes in the CM set are replaced; unspecified attributes are cleared. replace=True only supported for single asset (dict input), not batch.")] = False, + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Update custom metadata on one or more assets. Set replace=True for full replacement, False (default) for partial update. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + updates_parsed = json.loads(updates) if isinstance(updates, str) else updates + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('update_custom_metadata_tool', {'updates': updates_parsed, 'replace': replace, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='remove_custom_metadata_tool') +async def remove_custom_metadata_tool( + *, + guid: Annotated[str, cyclopts.Parameter(help="GUID of the asset")], + custom_metadata_name: Annotated[str, cyclopts.Parameter(help="Name of the custom metadata set to remove")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Remove a custom metadata set from an asset. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('remove_custom_metadata_tool', {'guid': guid, 'custom_metadata_name': custom_metadata_name, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='create_custom_metadata_set_tool') +async def create_custom_metadata_set_tool( + *, + sets: Annotated[str, cyclopts.Parameter(help="Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {\"display_name\": \"Data Quality\", \"attributes\": [{\"display_name\": \"Score\", \"attribute_type\": \"int\"}, {\"display_name\": \"Dimension\", \"attribute_type\": \"enum\", \"enum_values\": [\"Accuracy\", \"Completeness\"]}]}. Batch: [{\"display_name\": \"CM1\", \"attributes\": [...]}, {\"display_name\": \"CM2\", \"attributes\": [...]}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {\\\"display_name\\\": \\\"Data Quality\\\", \\\"attributes\\\": [{\\\"display_name\\\": \\\"Score\\\", \\\"attribute_type\\\": \\\"int\\\"}, {\\\"display_name\\\": \\\"Dimension\\\", \\\"attribute_type\\\": \\\"enum\\\", \\\"enum_values\\\": [\\\"Accuracy\\\", \\\"Completeness\\\"]}]}. Batch: [{\\\"display_name\\\": \\\"CM1\\\", \\\"attributes\\\": [...]}, {\\\"display_name\\\": \\\"CM2\\\", \\\"attributes\\\": [...]}]\"\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Create one or more custom metadata sets with typed attributes. Defines the schema; use update_custom_metadata to set values on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + sets_parsed = json.loads(sets) if isinstance(sets, str) else sets + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('create_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='delete_custom_metadata_set_tool') +async def delete_custom_metadata_set_tool( + *, + sets: Annotated[str, cyclopts.Parameter(help="Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: \"Data Quality\". Batch: [\"Data Quality\", \"Governance\"]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n }\n ],\n \"description\": \"Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: \\\"Data Quality\\\". Batch: [\\\"Data Quality\\\", \\\"Governance\\\"]\"\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Permanently delete one or more custom metadata sets and all their attribute values from assets. Irreversible. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + sets_parsed = json.loads(sets) if isinstance(sets, str) else sets + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('delete_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='add_attributes_to_cm_set_tool') +async def add_attributes_to_cm_set_tool( + *, + display_name: Annotated[str, cyclopts.Parameter(help="Display name of the existing CM set")], + attributes: Annotated[str, cyclopts.Parameter(help="Attributes to add. Each requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Example: [{\"display_name\": \"Risk Score\", \"attribute_type\": \"int\"}, {\"display_name\": \"Status\", \"attribute_type\": \"enum\", \"enum_values\": [\"Draft\", \"Approved\", \"Rejected\"]}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Attributes to add. Each requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Example: [{\\\"display_name\\\": \\\"Risk Score\\\", \\\"attribute_type\\\": \\\"int\\\"}, {\\\"display_name\\\": \\\"Status\\\", \\\"attribute_type\\\": \\\"enum\\\", \\\"enum_values\\\": [\\\"Draft\\\", \\\"Approved\\\", \\\"Rejected\\\"]}]\"\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Add new typed attributes to an existing custom metadata set. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + attributes_parsed = json.loads(attributes) if isinstance(attributes, str) else attributes + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('add_attributes_to_cm_set_tool', {'display_name': display_name, 'attributes': attributes_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='remove_attributes_from_cm_set_tool') +async def remove_attributes_from_cm_set_tool( + *, + display_name: Annotated[str, cyclopts.Parameter(help="Display name of the existing CM set")], + attribute_names: Annotated[str, cyclopts.Parameter(help="Display name(s) of attributes to remove (archive). Archived attributes are soft-deleted: values are cleared from assets and hidden in UI. Example: [\"Score\", \"Reviewed By\"] or a JSON string of the list.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Display name(s) of attributes to remove (archive). Archived attributes are soft-deleted: values are cleared from assets and hidden in UI. Example: [\\\"Score\\\", \\\"Reviewed By\\\"] or a JSON string of the list.\"\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Remove (archive) attributes from an existing custom metadata set. Archived attributes are soft-deleted and their values cleared from all assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + attribute_names_parsed = json.loads(attribute_names) if isinstance(attribute_names, str) else attribute_names + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('remove_attributes_from_cm_set_tool', {'display_name': display_name, 'attribute_names': attribute_names_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='add_atlan_tags_tool') +async def add_atlan_tags_tool( + *, + updates: Annotated[str, cyclopts.Parameter(help="Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {\"guid\": \"...\", \"tag_names\": [\"PII\"]}. Batch: [{\"guid\": \"g1\", \"tag_names\": [\"PII\"]}, ...]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {\\\"guid\\\": \\\"...\\\", \\\"tag_names\\\": [\\\"PII\\\"]}. Batch: [{\\\"guid\\\": \\\"g1\\\", \\\"tag_names\\\": [\\\"PII\\\"]}, ...]\"\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Add Atlan tags to one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + updates_parsed = json.loads(updates) if isinstance(updates, str) else updates + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('add_atlan_tags_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='remove_atlan_tag_tool') +async def remove_atlan_tag_tool( + *, + updates: Annotated[str, cyclopts.Parameter(help="Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {\"guid\": \"...\", \"tag_name\": \"PII\"}. Batch: [{\"guid\": \"g1\", \"tag_name\": \"PII\"}, ...]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {\\\"guid\\\": \\\"...\\\", \\\"tag_name\\\": \\\"PII\\\"}. Batch: [{\\\"guid\\\": \\\"g1\\\", \\\"tag_name\\\": \\\"PII\\\"}, ...]\",\n \"examples\": [\n {\n \"guid\": \"asset-guid-1\",\n \"tag_name\": \"PII\"\n },\n [\n {\n \"guid\": \"g1\",\n \"tag_name\": \"Sensitive\"\n },\n {\n \"guid\": \"g2\",\n \"tag_name\": \"Confidential\"\n }\n ]\n ]\n }")], + mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', + user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, +) -> None: + '''Remove an Atlan tag from one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + # Parse JSON parameters + updates_parsed = json.loads(updates) if isinstance(updates, str) else updates + user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + + await _call_tool('remove_atlan_tag_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) + + +@call_tool_app.command(name='get_asset_icons') +async def get_asset_icons( + *, + icon_names: Annotated[list[str], cyclopts.Parameter(help="")], +) -> None: + '''Fetch SVG icons for asset types. Internal tool used by widgets.''' + await _call_tool('get_asset_icons', {'icon_names': icon_names}) + + +if __name__ == "__main__": + app() From 832552562f33eeb4a200e03311e4470ba2307e3f Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 20:47:37 +0530 Subject: [PATCH 02/26] fix(mcp-cli): auto-load .env from script directory Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/atlan_cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 474dd77..c51a5d8 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -7,8 +7,13 @@ import json import os import sys +from pathlib import Path from typing import Annotated +from dotenv import load_dotenv + +load_dotenv(Path(__file__).parent / ".env", override=False) + import cyclopts import mcp.types from rich.console import Console From 6ea209115d136171cfd35cee695a5eee4445c902 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 20:48:31 +0530 Subject: [PATCH 03/26] fix(mcp-cli): add --oauth flag and ATLAN_AUTH=oauth to force OAuth mode Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/atlan_cli.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index c51a5d8..f92eed3 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -22,15 +22,17 @@ from fastmcp.client.auth import BearerAuth, OAuth # Auth resolution: -# ATLAN_BASE_URL + ATLAN_API_KEY → Bearer auth against {base_url}/mcp/api-key -# ATLAN_BASE_URL (no API key) → OAuth against {base_url}/mcp +# --oauth flag or ATLAN_AUTH=oauth → OAuth regardless of ATLAN_API_KEY +# ATLAN_BASE_URL + ATLAN_API_KEY → Bearer auth against {base_url}/mcp/api-key +# ATLAN_BASE_URL (no API key) → OAuth against {base_url}/mcp _base_url = os.environ.get("ATLAN_BASE_URL", "").rstrip("/") _api_key = os.environ.get("ATLAN_API_KEY") +_force_oauth = os.environ.get("ATLAN_AUTH", "").lower() == "oauth" or "--oauth" in sys.argv if not _base_url: raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") -if _api_key: +if _api_key and not _force_oauth: CLIENT_SPEC = f"{_base_url}/mcp/api-key" _auth = BearerAuth(_api_key) else: From 42bb821939a56149c82e32d9c552808a5a84a446 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 20:55:57 +0530 Subject: [PATCH 04/26] fix(mcp-cli): strip --oauth from argv before cyclopts; add README - sys.argv.remove("--oauth") prevents cyclopts from seeing the flag as an unknown command (was set inline before, still hit cyclopts) - README: fix incorrect generation URL reference (was mcp.atlan.com/mcp, which is an internal proxy); update regenerating command to use tenant URL; add --oauth and ATLAN_AUTH=oauth auth-override docs Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++ mcp-cli/atlan_cli.py | 5 +++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 mcp-cli/README.md diff --git a/mcp-cli/README.md b/mcp-cli/README.md new file mode 100644 index 0000000..9e26dc4 --- /dev/null +++ b/mcp-cli/README.md @@ -0,0 +1,68 @@ +# Atlan MCP CLI + +A standalone CLI for calling Atlan MCP tools directly from your terminal — no IDE, no agent required. + +Generated using [`fastmcp generate-cli`](https://gofastmcp.com/cli/generate-cli) against your Atlan tenant's MCP endpoint, and extended with dual auth support over Streamable HTTP. + +## Prerequisites + +```bash +pip install fastmcp python-dotenv +``` + +## Configuration + +Set your tenant URL and (optionally) your API key — either via environment variables or a `.env` file in the same directory as `atlan_cli.py`. + +```bash +# .env +ATLAN_BASE_URL=https://your-tenant.atlan.com +ATLAN_API_KEY=your-api-key # omit to use OAuth +``` + +## Auth Modes + +| Mode | How to activate | Endpoint used | +|------|----------------|---------------| +| **API key** | `ATLAN_BASE_URL` + `ATLAN_API_KEY` set | `{base_url}/mcp/api-key` | +| **OAuth (PKCE)** | `ATLAN_BASE_URL` only, no `ATLAN_API_KEY` | `{base_url}/mcp` | +| **Force OAuth** | `--oauth` flag or `ATLAN_AUTH=oauth` | `{base_url}/mcp` | + +The `--oauth` flag and `ATLAN_AUTH=oauth` are useful when `ATLAN_API_KEY` is set in `.env` but you want to authenticate via browser instead. + +## Usage + +```bash +# List all available tools +uv run python3 atlan_cli.py list-tools + +# Search assets +uv run python3 atlan_cli.py call-tool semantic_search_tool --user-query "PII tables in Snowflake" + +# Force OAuth even if API key is in .env +uv run python3 atlan_cli.py --oauth call-tool semantic_search_tool --user-query "PII tables" + +# Override auth via env var +ATLAN_AUTH=oauth uv run python3 atlan_cli.py call-tool semantic_search_tool --user-query "PII tables" +``` + +## Available Capabilities + +- **Search & discovery** — natural language search, structured filters, lineage traversal +- **Asset updates** — descriptions, owners, certificates, tags, announcements +- **Glossary** — create and manage glossaries, terms, and categories +- **Data governance** — domains and data products +- **Data quality** — create, update, schedule, and delete DQ rules +- **Custom metadata** — manage CM sets and attribute values + +Run `uv run python3 atlan_cli.py list-tools` for the full list with parameter details. + +## Regenerating the CLI + +If tool schemas change, regenerate with: + +```bash +fastmcp generate-cli https://your-tenant.atlan.com/mcp --auth oauth --output atlan_cli.py --force +``` + +Then re-apply the auth block at the top of the file (lines 15–38) — `fastmcp` does not write auth into generated scripts by design. diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index f92eed3..39c3502 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -27,7 +27,10 @@ # ATLAN_BASE_URL (no API key) → OAuth against {base_url}/mcp _base_url = os.environ.get("ATLAN_BASE_URL", "").rstrip("/") _api_key = os.environ.get("ATLAN_API_KEY") -_force_oauth = os.environ.get("ATLAN_AUTH", "").lower() == "oauth" or "--oauth" in sys.argv +_force_oauth = os.environ.get("ATLAN_AUTH", "").lower() == "oauth" +if "--oauth" in sys.argv: + _force_oauth = True + sys.argv.remove("--oauth") if not _base_url: raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") From 98325ac08c81a089dfe3d8b7be47d30e5cd37e30 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 21:01:34 +0530 Subject: [PATCH 05/26] fix(mcp-cli): add PEP 723 inline deps so uv run auto-installs fastmcp/dotenv Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/atlan_cli.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 39c3502..b6b59b1 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 -"""CLI for mcp.atlan.com MCP server. +# /// script +# requires-python = ">=3.10" +# dependencies = ["fastmcp", "python-dotenv", "mcp"] +# /// +"""CLI for Atlan MCP server. -Generated by: fastmcp generate-cli https://mcp.atlan.com/mcp +Generated by: fastmcp generate-cli https://your-tenant.atlan.com/mcp """ import json From c541aad661144baaecaaa32020aa550c58a2e548 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 21:11:18 +0530 Subject: [PATCH 06/26] feat(mcp-cli): persist OAuth tokens to ~/.atlan/mcp-tokens via FileTreeStore Eliminates browser re-auth on every CLI invocation. Tokens are stored per-server-URL in ~/.atlan/mcp-tokens using key_value FileTreeStore (already a transitive dep of fastmcp). Also fixes README uv run syntax. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/README.md | 10 +++++----- mcp-cli/atlan_cli.py | 7 ++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/mcp-cli/README.md b/mcp-cli/README.md index 9e26dc4..50aa64f 100644 --- a/mcp-cli/README.md +++ b/mcp-cli/README.md @@ -34,16 +34,16 @@ The `--oauth` flag and `ATLAN_AUTH=oauth` are useful when `ATLAN_API_KEY` is set ```bash # List all available tools -uv run python3 atlan_cli.py list-tools +uv run atlan_cli.py list-tools # Search assets -uv run python3 atlan_cli.py call-tool semantic_search_tool --user-query "PII tables in Snowflake" +uv run atlan_cli.py call-tool semantic_search_tool --user-query "PII tables in Snowflake" # Force OAuth even if API key is in .env -uv run python3 atlan_cli.py --oauth call-tool semantic_search_tool --user-query "PII tables" +uv run atlan_cli.py --oauth call-tool semantic_search_tool --user-query "PII tables" # Override auth via env var -ATLAN_AUTH=oauth uv run python3 atlan_cli.py call-tool semantic_search_tool --user-query "PII tables" +ATLAN_AUTH=oauth uv run atlan_cli.py call-tool semantic_search_tool --user-query "PII tables" ``` ## Available Capabilities @@ -55,7 +55,7 @@ ATLAN_AUTH=oauth uv run python3 atlan_cli.py call-tool semantic_search_tool --us - **Data quality** — create, update, schedule, and delete DQ rules - **Custom metadata** — manage CM sets and attribute values -Run `uv run python3 atlan_cli.py list-tools` for the full list with parameter details. +Run `uv run atlan_cli.py list-tools` for the full list with parameter details. ## Regenerating the CLI diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index b6b59b1..5d80b19 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -24,6 +24,7 @@ from fastmcp import Client from fastmcp.client.auth import BearerAuth, OAuth +from key_value.aio.stores.filetree.store import FileTreeStore # Auth resolution: # --oauth flag or ATLAN_AUTH=oauth → OAuth regardless of ATLAN_API_KEY @@ -39,12 +40,16 @@ if not _base_url: raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") +_token_dir = Path.home() / ".atlan" / "mcp-tokens" +_token_dir.mkdir(parents=True, exist_ok=True) +_token_store = FileTreeStore(directory=_token_dir) + if _api_key and not _force_oauth: CLIENT_SPEC = f"{_base_url}/mcp/api-key" _auth = BearerAuth(_api_key) else: CLIENT_SPEC = f"{_base_url}/mcp" - _auth = OAuth(mcp_url=CLIENT_SPEC) + _auth = OAuth(mcp_url=CLIENT_SPEC, token_storage=_token_store) app = cyclopts.App(name="mcp.atlan.com", help="CLI for mcp.atlan.com MCP server") call_tool_app = cyclopts.App(name="call-tool", help="Call a tool on the server") From 1cc775f117ab7ae071e0cc5c267627851d422878 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 21:19:06 +0530 Subject: [PATCH 07/26] fix(mcp-cli): replace FileTreeStore with inline JsonFileStore for token persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileTreeStore used keys directly as filesystem paths, which broke when keys were URLs (e.g. https:/tenant/mcp created nested directories that didn't exist). _JsonFileStore hashes the collection name for the filename and uses dict keys for key lookup — safe for any key string including URLs. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/atlan_cli.py | 55 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 5d80b19..12d80b5 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -8,11 +8,12 @@ Generated by: fastmcp generate-cli https://your-tenant.atlan.com/mcp """ +import hashlib import json import os import sys from pathlib import Path -from typing import Annotated +from typing import Annotated, Any, Mapping from dotenv import load_dotenv @@ -24,7 +25,53 @@ from fastmcp import Client from fastmcp.client.auth import BearerAuth, OAuth -from key_value.aio.stores.filetree.store import FileTreeStore + + +class _JsonFileStore: + """Minimal AsyncKeyValue store that persists tokens as JSON files in a directory. + + Avoids FileTreeStore's limitation of using keys as filesystem paths, which + breaks when keys are URLs containing slashes. + """ + + def __init__(self, path: Path) -> None: + self._path = path + self._path.mkdir(parents=True, exist_ok=True) + + def _file(self, collection: str) -> Path: + safe = hashlib.sha256(collection.encode()).hexdigest()[:16] + return self._path / f"{safe}.json" + + def _load(self, collection: str) -> dict: + f = self._file(collection) + return json.loads(f.read_text()) if f.exists() else {} + + def _save(self, collection: str, data: dict) -> None: + self._file(collection).write_text(json.dumps(data)) + + async def get(self, key: str, *, collection: str | None = None) -> dict[str, Any] | None: + return self._load(collection or "default").get(key) + + async def ttl(self, key: str, *, collection: str | None = None) -> tuple[dict[str, Any] | None, float | None]: + return self._load(collection or "default").get(key), None + + async def put(self, key: str, value: Mapping[str, Any], *, collection: str | None = None, ttl: Any = None) -> None: + data = self._load(collection or "default") + data[key] = dict(value) + self._save(collection or "default", data) + + async def delete(self, key: str, *, collection: str | None = None) -> bool: + data = self._load(collection or "default") + if key in data: + del data[key] + self._save(collection or "default", data) + return True + return False + + async def get_many(self, keys: list[str], *, collection: str | None = None) -> list[dict[str, Any] | None]: + data = self._load(collection or "default") + return [data.get(k) for k in keys] + # Auth resolution: # --oauth flag or ATLAN_AUTH=oauth → OAuth regardless of ATLAN_API_KEY @@ -40,9 +87,7 @@ if not _base_url: raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") -_token_dir = Path.home() / ".atlan" / "mcp-tokens" -_token_dir.mkdir(parents=True, exist_ok=True) -_token_store = FileTreeStore(directory=_token_dir) +_token_store = _JsonFileStore(Path.home() / ".atlan" / "mcp-tokens") if _api_key and not _force_oauth: CLIENT_SPEC = f"{_base_url}/mcp/api-key" From 9e663bc1b904f6a9f5d1842c83965fffa00de33d Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Mon, 27 Apr 2026 21:27:36 +0530 Subject: [PATCH 08/26] docs(mcp-cli): update README for uv auto-install, OAuth token caching - Prerequisites: uv run handles PEP 723 deps automatically, no pip install needed - Auth modes: note that OAuth tokens are cached in ~/.atlan/mcp-tokens/ - Regenerating: replace fragile line-number reference with structural marker Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mcp-cli/README.md b/mcp-cli/README.md index 50aa64f..701362a 100644 --- a/mcp-cli/README.md +++ b/mcp-cli/README.md @@ -6,10 +6,14 @@ Generated using [`fastmcp generate-cli`](https://gofastmcp.com/cli/generate-cli) ## Prerequisites +Install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) if you don't have it. All Python dependencies (`fastmcp`, `python-dotenv`, `mcp`) are declared inline via PEP 723 and installed automatically on first run: + ```bash -pip install fastmcp python-dotenv +uv run atlan_cli.py list-tools ``` +No `pip install` needed. + ## Configuration Set your tenant URL and (optionally) your API key — either via environment variables or a `.env` file in the same directory as `atlan_cli.py`. @@ -30,6 +34,8 @@ ATLAN_API_KEY=your-api-key # omit to use OAuth The `--oauth` flag and `ATLAN_AUTH=oauth` are useful when `ATLAN_API_KEY` is set in `.env` but you want to authenticate via browser instead. +OAuth tokens are cached in `~/.atlan/mcp-tokens/` after the first login — subsequent runs skip the browser entirely. + ## Usage ```bash @@ -65,4 +71,4 @@ If tool schemas change, regenerate with: fastmcp generate-cli https://your-tenant.atlan.com/mcp --auth oauth --output atlan_cli.py --force ``` -Then re-apply the auth block at the top of the file (lines 15–38) — `fastmcp` does not write auth into generated scripts by design. +Then re-apply the auth block at the top of the file (everything above `app = cyclopts.App(...)`) — `fastmcp` does not write auth into generated scripts by design. From d4d4e71b7aad721cd4ab06ce8fb45dc22971abc7 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 11:00:19 +0530 Subject: [PATCH 09/26] feat(mcp-cli): add pyproject.toml and flatten call-tool subcommand - Add pyproject.toml with setuptools build backend and atlan script entry point so users can install with `uv tool install .` instead of invoking via `uv run python3 atlan_cli.py` - Remove the `call-tool` sub-app; all tool commands now live directly on the root app so invocation is `atlan semantic_search_tool` instead of `atlan call-tool semantic_search_tool` - Add main() entry point function wired to the pyproject.toml [project.scripts] - Remove PEP 723 inline script metadata (superseded by pyproject.toml) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/atlan_cli.py | 109 ++++++++++++++++++++++++++--------------- mcp-cli/pyproject.toml | 22 +++++++++ 2 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 mcp-cli/pyproject.toml diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 12d80b5..9026fca 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -1,8 +1,4 @@ #!/usr/bin/env python3 -# /// script -# requires-python = ">=3.10" -# dependencies = ["fastmcp", "python-dotenv", "mcp"] -# /// """CLI for Atlan MCP server. Generated by: fastmcp generate-cli https://your-tenant.atlan.com/mcp @@ -27,6 +23,39 @@ from fastmcp.client.auth import BearerAuth, OAuth +class _KeyringStore: + """AsyncKeyValue store backed by the OS keychain (macOS Keychain, etc.). + + Tokens are encrypted at rest by the OS. Falls back to _JsonFileStore + if keyring is unavailable (headless servers, CI). + """ + + _SERVICE = "atlan-mcp" + + async def get(self, key: str, *, collection: str | None = None) -> dict[str, Any] | None: + import keyring + v = keyring.get_password(f"{self._SERVICE}/{collection or 'default'}", key) + return json.loads(v) if v else None + + async def ttl(self, key: str, *, collection: str | None = None) -> tuple[dict[str, Any] | None, float | None]: + return await self.get(key, collection=collection), None + + async def put(self, key: str, value: Mapping[str, Any], *, collection: str | None = None, ttl: Any = None) -> None: + import keyring + keyring.set_password(f"{self._SERVICE}/{collection or 'default'}", key, json.dumps(dict(value))) + + async def delete(self, key: str, *, collection: str | None = None) -> bool: + import keyring + try: + keyring.delete_password(f"{self._SERVICE}/{collection or 'default'}", key) + return True + except keyring.errors.PasswordDeleteError: + return False + + async def get_many(self, keys: list[str], *, collection: str | None = None) -> list[dict[str, Any] | None]: + return [await self.get(k, collection=collection) for k in keys] + + class _JsonFileStore: """Minimal AsyncKeyValue store that persists tokens as JSON files in a directory. @@ -87,7 +116,7 @@ async def get_many(self, keys: list[str], *, collection: str | None = None) -> l if not _base_url: raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") -_token_store = _JsonFileStore(Path.home() / ".atlan" / "mcp-tokens") +_token_store = _KeyringStore() if _api_key and not _force_oauth: CLIENT_SPEC = f"{_base_url}/mcp/api-key" @@ -96,9 +125,7 @@ async def get_many(self, keys: list[str], *, collection: str | None = None) -> l CLIENT_SPEC = f"{_base_url}/mcp" _auth = OAuth(mcp_url=CLIENT_SPEC, token_storage=_token_store) -app = cyclopts.App(name="mcp.atlan.com", help="CLI for mcp.atlan.com MCP server") -call_tool_app = cyclopts.App(name="call-tool", help="Call a tool on the server") -app.command(call_tool_app) +app = cyclopts.App(name="atlan", help="CLI for Atlan MCP server") console = Console() @@ -257,7 +284,7 @@ async def get_prompt( # Tool commands (generated from server schema) # --------------------------------------------------------------------------- -@call_tool_app.command(name='semantic_search_tool') +@app.command(name='semantic_search_tool') async def semantic_search_tool( *, user_query: Annotated[str, cyclopts.Parameter(help="Natural language search query")], @@ -273,7 +300,7 @@ async def semantic_search_tool( await _call_tool('semantic_search_tool', {'user_query': user_query, 'limit': limit_parsed, 'offset': offset_parsed, 'include_readme': include_readme}) -@call_tool_app.command(name='query_deep_sql_tool') +@app.command(name='query_deep_sql_tool') async def query_deep_sql_tool( *, sql: Annotated[str, cyclopts.Parameter(help="SELECT SQL query to execute against the Gold Layer.")], @@ -284,7 +311,7 @@ async def query_deep_sql_tool( await _call_tool('query_deep_sql_tool', {'sql': sql, 'limit': limit, 'offset': offset}) -@call_tool_app.command(name='search_assets_tool') +@app.command(name='search_assets_tool') async def search_assets_tool( *, conditions: Annotated[str | None, cyclopts.Parameter(help="Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {\"__categories\": \"\"} (direct only) or {\"__categories\": {\"operator\": \"within\", \"value\": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {\\\"__categories\\\": \\\"\\\"} (direct only) or {\\\"__categories\\\": {\\\"operator\\\": \\\"within\\\", \\\"value\\\": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.\",\n \"examples\": [\n {\n \"name\": \"customers\"\n },\n {\n \"name\": {\n \"operator\": \"startswith\",\n \"value\": \"dim_\"\n }\n },\n {\n \"certificate_status\": \"VERIFIED\"\n },\n {\n \"__typeName\": \"Table\"\n },\n {\n \"__categories\": \"\"\n },\n {\n \"__categories\": {\n \"operator\": \"within\",\n \"value\": [\n \"\",\n \"\",\n \"\"\n ]\n }\n }\n ]\n }")] = None, @@ -346,7 +373,7 @@ async def search_assets_tool( await _call_tool('search_assets_tool', {'conditions': conditions_parsed, 'negative_conditions': negative_conditions_parsed, 'some_conditions': some_conditions_parsed, 'min_somes': min_somes, 'include_attributes': include_attributes_parsed, 'asset_type': asset_type_parsed, 'glossary_qualified_name': glossary_qualified_name_parsed, 'include_archived': include_archived, 'limit': limit, 'offset': offset, 'sort_by': sort_by_parsed, 'sort_order': sort_order, 'sort': sort_parsed, 'connection_qualified_name': connection_qualified_name_parsed, 'tags': tags_parsed, 'directly_tagged': directly_tagged, 'domain_guids': domain_guids_parsed, 'date_range': date_range_parsed, 'guids': guids_parsed, 'term_guids': term_guids_parsed, 'aggregations': aggregations_parsed, 'count_only': count_only, 'scroll': scroll, 'include_readme': include_readme, 'user_query': user_query_parsed}) -@call_tool_app.command(name='traverse_lineage_tool') +@app.command(name='traverse_lineage_tool') async def traverse_lineage_tool( *, guid: Annotated[str, cyclopts.Parameter(help="GUID of the starting asset")], @@ -375,7 +402,7 @@ async def traverse_lineage_tool( await _call_tool('traverse_lineage_tool', {'guid': guid, 'direction': direction, 'depth': depth, 'size': size, 'immediate_neighbors': immediate_neighbors, 'offset': offset, 'include_attributes': include_attributes_parsed, 'user_query': user_query_parsed}) -@call_tool_app.command(name='query_assets_tool') +@app.command(name='query_assets_tool') async def query_assets_tool( *, sql: Annotated[str, cyclopts.Parameter(help="SQL query to execute against the connection")], @@ -391,7 +418,7 @@ async def query_assets_tool( await _call_tool('query_assets_tool', {'sql': sql, 'connection_qualified_name': connection_qualified_name, 'default_schema': default_schema_parsed, 'user_query': user_query_parsed}) -@call_tool_app.command(name='resolve_metadata_tool') +@app.command(name='resolve_metadata_tool') async def resolve_metadata_tool( *, namespace_type: Annotated[str, cyclopts.Parameter(help="Metadata namespace to search. Use 'data_domain_and_product' for BOTH data domains AND data products.")], @@ -406,7 +433,7 @@ async def resolve_metadata_tool( await _call_tool('resolve_metadata_tool', {'namespace_type': namespace_type, 'query': query, 'limit': limit, 'user_query': user_query_parsed}) -@call_tool_app.command(name='search_atlan_docs_tool') +@app.command(name='search_atlan_docs_tool') async def search_atlan_docs_tool( *, query: Annotated[str, cyclopts.Parameter(help="Question about Atlan features, how-tos, or configuration. E.g. 'How do I connect Snowflake to Atlan?' or 'How do I create a data product?'")], @@ -420,7 +447,7 @@ async def search_atlan_docs_tool( await _call_tool('search_atlan_docs_tool', {'query': query, 'top_k': top_k, 'user_query': user_query_parsed}) -@call_tool_app.command(name='get_groups_tool') +@app.command(name='get_groups_tool') async def get_groups_tool( *, name_filter: Annotated[str | None, cyclopts.Parameter(help="Filter by name (partial match)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by name (partial match)\"\n }")] = None, @@ -439,7 +466,7 @@ async def get_groups_tool( await _call_tool('get_groups_tool', {'name_filter': name_filter_parsed, 'group_id': group_id_parsed, 'include_members': include_members, 'limit': limit, 'offset': offset, 'user_query': user_query_parsed}) -@call_tool_app.command(name='get_asset_tool') +@app.command(name='get_asset_tool') async def get_asset_tool( *, guid: Annotated[str | None, cyclopts.Parameter(help="Asset GUID to retrieve\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset GUID to retrieve\"\n }")] = None, @@ -461,7 +488,7 @@ async def get_asset_tool( await _call_tool('get_asset_tool', {'guid': guid_parsed, 'qualified_name': qualified_name_parsed, 'asset_type': asset_type_parsed, 'include_attributes': include_attributes_parsed, 'include_dq_checks': include_dq_checks, 'include_readme': include_readme, 'user_query': user_query_parsed}) -@call_tool_app.command(name='update_assets_tool') +@app.command(name='update_assets_tool') async def update_assets_tool( *, updates: Annotated[str, cyclopts.Parameter(help="Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly 'append' (link terms), 'remove' (unlink terms), or 'replace' (overwrite all). Do NOT use 'add' or 'delete' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is 'append', 'replace', or 'remove' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly 'append' (link terms), 'remove' (unlink terms), or 'replace' (overwrite all). Do NOT use 'add' or 'delete' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is 'append', 'replace', or 'remove' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.\",\n \"examples\": [\n {\n \"certificate_status\": \"VERIFIED\",\n \"guid\": \"abc-123\",\n \"name\": \"customers\",\n \"qualified_name\": \"default/snowflake/db/schema/customers\",\n \"type_name\": \"Table\",\n \"user_description\": \"Customer master data\"\n },\n [\n {\n \"guid\": \"abc-123\",\n \"name\": \"t1\",\n \"owner_users\": [\n \"user1\",\n \"user2\"\n ],\n \"qualified_name\": \"qn1\",\n \"type_name\": \"Table\"\n },\n {\n \"guid\": \"def-456\",\n \"name\": \"t2\",\n \"qualified_name\": \"qn2\",\n \"term\": {\n \"operation\": \"append\",\n \"term_guids\": [\n \"guid1\"\n ]\n },\n \"type_name\": \"Table\"\n }\n ],\n [\n {\n \"certificate_status\": \"DRAFT\",\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-456\",\n \"name\": \"Churn Rate\",\n \"qualified_name\": \"abc@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\"\n },\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-789\",\n \"name\": \"Revenue\",\n \"qualified_name\": \"def@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\",\n \"user_description\": \"Total income\"\n }\n ],\n {\n \"category_guids\": [\n \"cat-guid-1\",\n \"cat-guid-2\"\n ],\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-101\",\n \"name\": \"Net Revenue\",\n \"qualified_name\": \"ghi@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\"\n },\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-202\",\n \"name\": \"Revenue\",\n \"qualified_name\": \"jkl@glossary-qn\",\n \"term_relations\": {\n \"antonyms\": {\n \"guids\": [\n \"guid-of-cost\"\n ],\n \"op\": \"append\"\n },\n \"synonyms\": {\n \"guids\": [\n \"guid-of-income\",\n \"guid-of-earnings\"\n ],\n \"op\": \"append\"\n }\n },\n \"type_name\": \"AtlasGlossaryTerm\"\n }\n ]\n }")], @@ -476,7 +503,7 @@ async def update_assets_tool( await _call_tool('update_assets_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='create_glossaries') +@app.command(name='create_glossaries') async def create_glossaries( *, glossaries: Annotated[str, cyclopts.Parameter(help="Glossary definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Glossary definitions\",\n \"examples\": [\n {\n \"name\": \"Business Glossary\",\n \"user_description\": \"Company-wide terms\"\n },\n [\n {\n \"name\": \"Glossary A\"\n },\n {\n \"certificate_status\": \"VERIFIED\",\n \"name\": \"Glossary B\"\n }\n ]\n ]\n }")], @@ -491,7 +518,7 @@ async def create_glossaries( await _call_tool('create_glossaries', {'glossaries': glossaries_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='create_glossary_terms') +@app.command(name='create_glossary_terms') async def create_glossary_terms( *, terms: Annotated[str, cyclopts.Parameter(help="Term definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Term definitions\",\n \"examples\": [\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"name\": \"Revenue\",\n \"user_description\": \"Total income\"\n },\n [\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Term A\"\n },\n {\n \"category_guids\": [\n \"cat-1\"\n ],\n \"glossary_guid\": \"g-123\",\n \"name\": \"Term B\"\n }\n ]\n ]\n }")], @@ -506,7 +533,7 @@ async def create_glossary_terms( await _call_tool('create_glossary_terms', {'terms': terms_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='create_glossary_categories') +@app.command(name='create_glossary_categories') async def create_glossary_categories( *, categories: Annotated[str, cyclopts.Parameter(help="Category definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Category definitions\",\n \"examples\": [\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"name\": \"Finance Terms\"\n },\n [\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Cat A\"\n },\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Cat B\",\n \"parent_category_guid\": \"cat-a-guid\"\n }\n ]\n ]\n }")], @@ -521,7 +548,7 @@ async def create_glossary_categories( await _call_tool('create_glossary_categories', {'categories': categories_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='create_domains') +@app.command(name='create_domains') async def create_domains( *, domains: Annotated[str, cyclopts.Parameter(help="Domain definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Domain definitions\",\n \"examples\": [\n {\n \"name\": \"Sales Domain\",\n \"user_description\": \"Sales analytics data\"\n },\n [\n {\n \"name\": \"Finance\"\n },\n {\n \"name\": \"Marketing\",\n \"parent_domain_qualified_name\": \"default/domain/Finance\"\n }\n ]\n ]\n }")], @@ -536,7 +563,7 @@ async def create_domains( await _call_tool('create_domains', {'domains': domains_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='create_data_products') +@app.command(name='create_data_products') async def create_data_products( *, products: Annotated[str, cyclopts.Parameter(help="Product definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Product definitions\",\n \"examples\": [\n {\n \"asset_guids\": [\n \"asset-guid-1\"\n ],\n \"domain_qualified_name\": \"default/domain/Sales\",\n \"name\": \"Customer 360\"\n },\n [\n {\n \"asset_guids\": [\n \"g1\",\n \"g2\"\n ],\n \"domain_qualified_name\": \"default/domain/D1\",\n \"name\": \"Prod A\",\n \"user_description\": \"Product A\"\n }\n ]\n ]\n }")], @@ -551,7 +578,7 @@ async def create_data_products( await _call_tool('create_data_products', {'products': products_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='create_dq_rules_tool') +@app.command(name='create_dq_rules_tool') async def create_dq_rules_tool( *, rules: Annotated[str, cyclopts.Parameter(help="DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is 'Custom SQL'. rule_name (str): display name, required when rule_type is 'Custom SQL'. dimension (str): required when rule_type is 'Custom SQL': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is 'Custom SQL'. rule_name (str): display name, required when rule_type is 'Custom SQL'. dimension (str): required when rule_type is 'Custom SQL': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.\",\n \"examples\": [\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/table/column\",\n \"rule_type\": \"Null Count\",\n \"threshold_compare_operator\": \"EQ\",\n \"threshold_value\": 0\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"rule_type\": \"Row Count\",\n \"threshold_compare_operator\": \"GTE\",\n \"threshold_value\": 100\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"custom_sql\": \"SELECT COUNT(*) FROM table\",\n \"dimension\": \"COMPLETENESS\",\n \"rule_name\": \"MyRule\",\n \"rule_type\": \"Custom SQL\",\n \"threshold_compare_operator\": \"EQ\",\n \"threshold_value\": 0\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/source_table\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/target_table\",\n \"rule_type\": \"Recon Row Count\",\n \"threshold_unit\": \"PERCENTAGE\",\n \"threshold_value\": 3\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/source_table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/source_table/col\",\n \"reference_column_qualified_name\": \"default/snowflake/db/schema/target_table/col\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/target_table\",\n \"rule_type\": \"Recon Unique Count\",\n \"threshold_unit\": \"PERCENTAGE\",\n \"threshold_value\": 2\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/table/country\",\n \"reference_column_qualified_name\": \"default/snowflake/db/schema/valid_countries/code\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/valid_countries\",\n \"rule_type\": \"Valid Values Reference\",\n \"threshold_value\": 2\n }\n ]\n }")], @@ -566,7 +593,7 @@ async def create_dq_rules_tool( await _call_tool('create_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='schedule_dq_rules_tool') +@app.command(name='schedule_dq_rules_tool') async def schedule_dq_rules_tool( *, schedules: Annotated[str, cyclopts.Parameter(help="Schedule definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Schedule definitions\",\n \"examples\": [\n {\n \"asset_name\": \"customers\",\n \"asset_qualified_name\": \"default/snowflake/db/schema/customers\",\n \"asset_type\": \"Table\",\n \"schedule_crontab\": \"0 6 * * *\",\n \"schedule_time_zone\": \"America/New_York\"\n },\n [\n {\n \"asset_name\": \"v1\",\n \"asset_qualified_name\": \"qn1\",\n \"asset_type\": \"View\",\n \"schedule_crontab\": \"0 */4 * * *\",\n \"schedule_time_zone\": \"UTC\"\n }\n ]\n ]\n }")], @@ -581,7 +608,7 @@ async def schedule_dq_rules_tool( await _call_tool('schedule_dq_rules_tool', {'schedules': schedules_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='update_dq_rules_tool') +@app.command(name='update_dq_rules_tool') async def update_dq_rules_tool( *, rules: Annotated[str, cyclopts.Parameter(help="DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.\",\n \"examples\": [\n {\n \"alert_priority\": \"URGENT\",\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"qualified_name\": \"default/dq/rule-qn\",\n \"rule_type\": \"Null Count\",\n \"threshold_value\": 5\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"qualified_name\": \"default/dq/rule-qn\",\n \"rule_type\": \"Freshness\",\n \"threshold_unit\": \"HOURS\",\n \"threshold_value\": 24\n }\n ]\n }")], @@ -596,7 +623,7 @@ async def update_dq_rules_tool( await _call_tool('update_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='delete_dq_rules_tool') +@app.command(name='delete_dq_rules_tool') async def delete_dq_rules_tool( *, rule_guids: Annotated[str, cyclopts.Parameter(help="GUIDs of DQ rules to delete\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"GUIDs of DQ rules to delete\"\n }")], @@ -611,7 +638,7 @@ async def delete_dq_rules_tool( await _call_tool('delete_dq_rules_tool', {'rule_guids': rule_guids_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='manage_asset_lifecycle_tool') +@app.command(name='manage_asset_lifecycle_tool') async def manage_asset_lifecycle_tool( *, operation: Annotated[str, cyclopts.Parameter(help="Lifecycle operation to perform")], @@ -631,7 +658,7 @@ async def manage_asset_lifecycle_tool( await _call_tool('manage_asset_lifecycle_tool', {'operation': operation, 'guids': guids_parsed, 'asset_type': asset_type_parsed, 'qualified_name': qualified_name_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='manage_announcements_tool') +@app.command(name='manage_announcements_tool') async def manage_announcements_tool( *, asset_guids: Annotated[str, cyclopts.Parameter(help="Asset GUIDs to update (comma-separated or JSON array)")], @@ -652,7 +679,7 @@ async def manage_announcements_tool( await _call_tool('manage_announcements_tool', {'asset_guids': asset_guids, 'operation': operation, 'announcement_type': announcement_type_parsed, 'title': title_parsed, 'message': message_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='update_custom_metadata_tool') +@app.command(name='update_custom_metadata_tool') async def update_custom_metadata_tool( *, updates: Annotated[str, cyclopts.Parameter(help="Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {\"guid\": \"...\", \"custom_metadata_name\": \"CM\", \"attributes\": {\"k\": \"v\"}}. Batch: [{\"guid\": \"g1\", ...}, {\"guid\": \"g2\", ...}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {\\\"guid\\\": \\\"...\\\", \\\"custom_metadata_name\\\": \\\"CM\\\", \\\"attributes\\\": {\\\"k\\\": \\\"v\\\"}}. Batch: [{\\\"guid\\\": \\\"g1\\\", ...}, {\\\"guid\\\": \\\"g2\\\", ...}]\"\n }")], @@ -668,7 +695,7 @@ async def update_custom_metadata_tool( await _call_tool('update_custom_metadata_tool', {'updates': updates_parsed, 'replace': replace, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='remove_custom_metadata_tool') +@app.command(name='remove_custom_metadata_tool') async def remove_custom_metadata_tool( *, guid: Annotated[str, cyclopts.Parameter(help="GUID of the asset")], @@ -683,7 +710,7 @@ async def remove_custom_metadata_tool( await _call_tool('remove_custom_metadata_tool', {'guid': guid, 'custom_metadata_name': custom_metadata_name, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='create_custom_metadata_set_tool') +@app.command(name='create_custom_metadata_set_tool') async def create_custom_metadata_set_tool( *, sets: Annotated[str, cyclopts.Parameter(help="Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {\"display_name\": \"Data Quality\", \"attributes\": [{\"display_name\": \"Score\", \"attribute_type\": \"int\"}, {\"display_name\": \"Dimension\", \"attribute_type\": \"enum\", \"enum_values\": [\"Accuracy\", \"Completeness\"]}]}. Batch: [{\"display_name\": \"CM1\", \"attributes\": [...]}, {\"display_name\": \"CM2\", \"attributes\": [...]}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {\\\"display_name\\\": \\\"Data Quality\\\", \\\"attributes\\\": [{\\\"display_name\\\": \\\"Score\\\", \\\"attribute_type\\\": \\\"int\\\"}, {\\\"display_name\\\": \\\"Dimension\\\", \\\"attribute_type\\\": \\\"enum\\\", \\\"enum_values\\\": [\\\"Accuracy\\\", \\\"Completeness\\\"]}]}. Batch: [{\\\"display_name\\\": \\\"CM1\\\", \\\"attributes\\\": [...]}, {\\\"display_name\\\": \\\"CM2\\\", \\\"attributes\\\": [...]}]\"\n }")], @@ -698,7 +725,7 @@ async def create_custom_metadata_set_tool( await _call_tool('create_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='delete_custom_metadata_set_tool') +@app.command(name='delete_custom_metadata_set_tool') async def delete_custom_metadata_set_tool( *, sets: Annotated[str, cyclopts.Parameter(help="Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: \"Data Quality\". Batch: [\"Data Quality\", \"Governance\"]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n }\n ],\n \"description\": \"Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: \\\"Data Quality\\\". Batch: [\\\"Data Quality\\\", \\\"Governance\\\"]\"\n }")], @@ -713,7 +740,7 @@ async def delete_custom_metadata_set_tool( await _call_tool('delete_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='add_attributes_to_cm_set_tool') +@app.command(name='add_attributes_to_cm_set_tool') async def add_attributes_to_cm_set_tool( *, display_name: Annotated[str, cyclopts.Parameter(help="Display name of the existing CM set")], @@ -729,7 +756,7 @@ async def add_attributes_to_cm_set_tool( await _call_tool('add_attributes_to_cm_set_tool', {'display_name': display_name, 'attributes': attributes_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='remove_attributes_from_cm_set_tool') +@app.command(name='remove_attributes_from_cm_set_tool') async def remove_attributes_from_cm_set_tool( *, display_name: Annotated[str, cyclopts.Parameter(help="Display name of the existing CM set")], @@ -745,7 +772,7 @@ async def remove_attributes_from_cm_set_tool( await _call_tool('remove_attributes_from_cm_set_tool', {'display_name': display_name, 'attribute_names': attribute_names_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='add_atlan_tags_tool') +@app.command(name='add_atlan_tags_tool') async def add_atlan_tags_tool( *, updates: Annotated[str, cyclopts.Parameter(help="Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {\"guid\": \"...\", \"tag_names\": [\"PII\"]}. Batch: [{\"guid\": \"g1\", \"tag_names\": [\"PII\"]}, ...]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {\\\"guid\\\": \\\"...\\\", \\\"tag_names\\\": [\\\"PII\\\"]}. Batch: [{\\\"guid\\\": \\\"g1\\\", \\\"tag_names\\\": [\\\"PII\\\"]}, ...]\"\n }")], @@ -760,7 +787,7 @@ async def add_atlan_tags_tool( await _call_tool('add_atlan_tags_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='remove_atlan_tag_tool') +@app.command(name='remove_atlan_tag_tool') async def remove_atlan_tag_tool( *, updates: Annotated[str, cyclopts.Parameter(help="Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {\"guid\": \"...\", \"tag_name\": \"PII\"}. Batch: [{\"guid\": \"g1\", \"tag_name\": \"PII\"}, ...]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {\\\"guid\\\": \\\"...\\\", \\\"tag_name\\\": \\\"PII\\\"}. Batch: [{\\\"guid\\\": \\\"g1\\\", \\\"tag_name\\\": \\\"PII\\\"}, ...]\",\n \"examples\": [\n {\n \"guid\": \"asset-guid-1\",\n \"tag_name\": \"PII\"\n },\n [\n {\n \"guid\": \"g1\",\n \"tag_name\": \"Sensitive\"\n },\n {\n \"guid\": \"g2\",\n \"tag_name\": \"Confidential\"\n }\n ]\n ]\n }")], @@ -775,7 +802,7 @@ async def remove_atlan_tag_tool( await _call_tool('remove_atlan_tag_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) -@call_tool_app.command(name='get_asset_icons') +@app.command(name='get_asset_icons') async def get_asset_icons( *, icon_names: Annotated[list[str], cyclopts.Parameter(help="")], @@ -784,5 +811,9 @@ async def get_asset_icons( await _call_tool('get_asset_icons', {'icon_names': icon_names}) -if __name__ == "__main__": +def main() -> None: app() + + +if __name__ == "__main__": + main() diff --git a/mcp-cli/pyproject.toml b/mcp-cli/pyproject.toml new file mode 100644 index 0000000..ac9f94f --- /dev/null +++ b/mcp-cli/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "atlan-mcp-cli" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "cyclopts", + "fastmcp", + "python-dotenv", + "mcp", + "keyring", + "rich", +] + +[project.scripts] +atlan = "atlan_cli:main" + +[tool.setuptools] +py-modules = ["atlan_cli"] From 052ee1e6805bc7c93ea083688154db7cbedd09b6 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 11:12:08 +0530 Subject: [PATCH 10/26] fix(mcp-cli): lazy auth init so --help works without ATLAN_BASE_URL Move auth resolution from module-level to a lazy _resolve_auth() function called only when a command actually connects to the server. Also fixes --oauth handling by stripping it in main() and setting ATLAN_AUTH=oauth. Also fix pyproject.toml build-backend to setuptools.build_meta (the legacy backend path was removed in newer setuptools versions). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/atlan_cli.py | 56 +++++++++++++++++++++++------------------- mcp-cli/pyproject.toml | 2 +- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 9026fca..8530a8e 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -102,28 +102,31 @@ async def get_many(self, keys: list[str], *, collection: str | None = None) -> l return [data.get(k) for k in keys] -# Auth resolution: +# Auth resolution (lazy — evaluated on first tool/command call): # --oauth flag or ATLAN_AUTH=oauth → OAuth regardless of ATLAN_API_KEY # ATLAN_BASE_URL + ATLAN_API_KEY → Bearer auth against {base_url}/mcp/api-key # ATLAN_BASE_URL (no API key) → OAuth against {base_url}/mcp -_base_url = os.environ.get("ATLAN_BASE_URL", "").rstrip("/") -_api_key = os.environ.get("ATLAN_API_KEY") -_force_oauth = os.environ.get("ATLAN_AUTH", "").lower() == "oauth" -if "--oauth" in sys.argv: - _force_oauth = True - sys.argv.remove("--oauth") - -if not _base_url: - raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") - -_token_store = _KeyringStore() - -if _api_key and not _force_oauth: - CLIENT_SPEC = f"{_base_url}/mcp/api-key" - _auth = BearerAuth(_api_key) -else: - CLIENT_SPEC = f"{_base_url}/mcp" - _auth = OAuth(mcp_url=CLIENT_SPEC, token_storage=_token_store) +_resolved: tuple[str, object] | None = None + + +def _resolve_auth() -> tuple[str, object]: + global _resolved + if _resolved is not None: + return _resolved + base_url = os.environ.get("ATLAN_BASE_URL", "").rstrip("/") + if not base_url: + raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") + api_key = os.environ.get("ATLAN_API_KEY") + force_oauth = os.environ.get("ATLAN_AUTH", "").lower() == "oauth" + token_store = _KeyringStore() + if api_key and not force_oauth: + client_spec = f"{base_url}/mcp/api-key" + auth = BearerAuth(api_key) + else: + client_spec = f"{base_url}/mcp" + auth = OAuth(mcp_url=client_spec, token_storage=token_store) + _resolved = (client_spec, auth) + return _resolved app = cyclopts.App(name="atlan", help="CLI for Atlan MCP server") @@ -166,7 +169,7 @@ async def _call_tool(tool_name: str, arguments: dict) -> None: for k, v in arguments.items() if v is not None and (not isinstance(v, list) or len(v) > 0) } - async with Client(CLIENT_SPEC, auth=_auth) as client: + async with Client(*_resolve_auth()) as client: result = await client.call_tool(tool_name, filtered, raise_on_error=False) _print_tool_result(result) if result.is_error: @@ -181,7 +184,7 @@ async def _call_tool(tool_name: str, arguments: dict) -> None: @app.command async def list_tools() -> None: """List available tools.""" - async with Client(CLIENT_SPEC, auth=_auth) as client: + async with Client(*_resolve_auth()) as client: tools = await client.list_tools() if not tools: console.print("[dim]No tools found.[/dim]") @@ -206,7 +209,7 @@ async def list_tools() -> None: @app.command async def list_resources() -> None: """List available resources.""" - async with Client(CLIENT_SPEC, auth=_auth) as client: + async with Client(*_resolve_auth()) as client: resources = await client.list_resources() if not resources: console.print("[dim]No resources found.[/dim]") @@ -223,7 +226,7 @@ async def list_resources() -> None: @app.command async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource URI")]) -> None: """Read a resource by URI.""" - async with Client(CLIENT_SPEC, auth=_auth) as client: + async with Client(*_resolve_auth()) as client: contents = await client.read_resource(uri) for block in contents: if isinstance(block, mcp.types.TextResourceContents): @@ -236,7 +239,7 @@ async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource UR @app.command async def list_prompts() -> None: """List available prompts.""" - async with Client(CLIENT_SPEC, auth=_auth) as client: + async with Client(*_resolve_auth()) as client: prompts = await client.list_prompts() if not prompts: console.print("[dim]No prompts found.[/dim]") @@ -266,7 +269,7 @@ async def get_prompt( key, value = arg.split("=", 1) parsed[key] = value - async with Client(CLIENT_SPEC, auth=_auth) as client: + async with Client(*_resolve_auth()) as client: result = await client.get_prompt(name, parsed or None) for msg in result.messages: console.print(f"[bold]{msg.role}:[/bold]") @@ -812,6 +815,9 @@ async def get_asset_icons( def main() -> None: + if "--oauth" in sys.argv: + sys.argv.remove("--oauth") + os.environ["ATLAN_AUTH"] = "oauth" app() diff --git a/mcp-cli/pyproject.toml b/mcp-cli/pyproject.toml index ac9f94f..30a3053 100644 --- a/mcp-cli/pyproject.toml +++ b/mcp-cli/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = ["setuptools>=61"] -build-backend = "setuptools.backends.legacy:build" +build-backend = "setuptools.build_meta" [project] name = "atlan-mcp-cli" From 483e059c09b14f5b3ad5b07af50ca8cc1922f54f Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 11:34:58 +0530 Subject: [PATCH 11/26] fix(mcp-cli): pass auth as keyword arg and load .env from cwd - Fix critical bug: Client(*_resolve_auth()) was mapping the OAuth handler to the `name` param (2nd positional arg), not `auth`. Replace all call sites with _client() helper that explicitly passes auth=auth as a keyword argument. - Load .env from cwd before script-dir so installed tool respects a .env in the working directory. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/atlan_cli.py | 18 ++++++++++++------ mcp-cli/pyproject.toml | 3 +-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 8530a8e..c272df3 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -13,6 +13,7 @@ from dotenv import load_dotenv +load_dotenv(Path.cwd() / ".env", override=False) load_dotenv(Path(__file__).parent / ".env", override=False) import cyclopts @@ -128,6 +129,11 @@ def _resolve_auth() -> tuple[str, object]: _resolved = (client_spec, auth) return _resolved +def _client() -> "Client": + url, auth = _resolve_auth() + return Client(url, auth=auth) + + app = cyclopts.App(name="atlan", help="CLI for Atlan MCP server") console = Console() @@ -169,7 +175,7 @@ async def _call_tool(tool_name: str, arguments: dict) -> None: for k, v in arguments.items() if v is not None and (not isinstance(v, list) or len(v) > 0) } - async with Client(*_resolve_auth()) as client: + async with _client() as client: result = await client.call_tool(tool_name, filtered, raise_on_error=False) _print_tool_result(result) if result.is_error: @@ -184,7 +190,7 @@ async def _call_tool(tool_name: str, arguments: dict) -> None: @app.command async def list_tools() -> None: """List available tools.""" - async with Client(*_resolve_auth()) as client: + async with _client() as client: tools = await client.list_tools() if not tools: console.print("[dim]No tools found.[/dim]") @@ -209,7 +215,7 @@ async def list_tools() -> None: @app.command async def list_resources() -> None: """List available resources.""" - async with Client(*_resolve_auth()) as client: + async with _client() as client: resources = await client.list_resources() if not resources: console.print("[dim]No resources found.[/dim]") @@ -226,7 +232,7 @@ async def list_resources() -> None: @app.command async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource URI")]) -> None: """Read a resource by URI.""" - async with Client(*_resolve_auth()) as client: + async with _client() as client: contents = await client.read_resource(uri) for block in contents: if isinstance(block, mcp.types.TextResourceContents): @@ -239,7 +245,7 @@ async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource UR @app.command async def list_prompts() -> None: """List available prompts.""" - async with Client(*_resolve_auth()) as client: + async with _client() as client: prompts = await client.list_prompts() if not prompts: console.print("[dim]No prompts found.[/dim]") @@ -269,7 +275,7 @@ async def get_prompt( key, value = arg.split("=", 1) parsed[key] = value - async with Client(*_resolve_auth()) as client: + async with _client() as client: result = await client.get_prompt(name, parsed or None) for msg in result.messages: console.print(f"[bold]{msg.role}:[/bold]") diff --git a/mcp-cli/pyproject.toml b/mcp-cli/pyproject.toml index 30a3053..dca3be7 100644 --- a/mcp-cli/pyproject.toml +++ b/mcp-cli/pyproject.toml @@ -8,9 +8,8 @@ version = "0.1.0" requires-python = ">=3.10" dependencies = [ "cyclopts", - "fastmcp", + "fastmcp>=2.14,<3", "python-dotenv", - "mcp", "keyring", "rich", ] From ca94098a3dc66d44ad87a26e388de2dde7fe2149 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 11:55:02 +0530 Subject: [PATCH 12/26] docs(mcp-cli): update README for installable package and flat commands Reflect new uv tool install workflow, direct tool invocation without the call-tool prefix, and OAuth token caching behaviour. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/README.md | 78 +++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/mcp-cli/README.md b/mcp-cli/README.md index 701362a..3d76ec2 100644 --- a/mcp-cli/README.md +++ b/mcp-cli/README.md @@ -2,73 +2,93 @@ A standalone CLI for calling Atlan MCP tools directly from your terminal — no IDE, no agent required. -Generated using [`fastmcp generate-cli`](https://gofastmcp.com/cli/generate-cli) against your Atlan tenant's MCP endpoint, and extended with dual auth support over Streamable HTTP. +## Installation -## Prerequisites - -Install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) if you don't have it. All Python dependencies (`fastmcp`, `python-dotenv`, `mcp`) are declared inline via PEP 723 and installed automatically on first run: +Install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) if you don't have it, then install the CLI as a global tool: ```bash -uv run atlan_cli.py list-tools +uv tool install /path/to/agent-toolkit/mcp-cli ``` -No `pip install` needed. +This gives you the `atlan` command globally. Add `~/.local/bin` to your PATH if prompted: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` ## Configuration -Set your tenant URL and (optionally) your API key — either via environment variables or a `.env` file in the same directory as `atlan_cli.py`. +Set your tenant URL and (optionally) an API key — either via environment variables or a `.env` file in the directory where you run `atlan`: ```bash # .env ATLAN_BASE_URL=https://your-tenant.atlan.com -ATLAN_API_KEY=your-api-key # omit to use OAuth +ATLAN_API_KEY=your-api-key # omit to use OAuth browser login ``` ## Auth Modes | Mode | How to activate | Endpoint used | |------|----------------|---------------| -| **API key** | `ATLAN_BASE_URL` + `ATLAN_API_KEY` set | `{base_url}/mcp/api-key` | +| **API key** | `ATLAN_BASE_URL` + `ATLAN_API_KEY` | `{base_url}/mcp/api-key` | | **OAuth (PKCE)** | `ATLAN_BASE_URL` only, no `ATLAN_API_KEY` | `{base_url}/mcp` | | **Force OAuth** | `--oauth` flag or `ATLAN_AUTH=oauth` | `{base_url}/mcp` | -The `--oauth` flag and `ATLAN_AUTH=oauth` are useful when `ATLAN_API_KEY` is set in `.env` but you want to authenticate via browser instead. +OAuth tokens are cached in the OS keychain after the first browser login — subsequent runs skip the browser entirely. -OAuth tokens are cached in `~/.atlan/mcp-tokens/` after the first login — subsequent runs skip the browser entirely. +The `--oauth` flag forces browser login even when `ATLAN_API_KEY` is set in `.env`. ## Usage ```bash -# List all available tools -uv run atlan_cli.py list-tools +# Show all available commands +atlan --help + +# List tools the MCP server exposes +atlan --oauth list-tools + +# Search assets (OAuth) +atlan --oauth semantic_search_tool --user-query "PII tables in Snowflake" -# Search assets -uv run atlan_cli.py call-tool semantic_search_tool --user-query "PII tables in Snowflake" +# Search assets (API key — reads .env automatically) +atlan semantic_search_tool --user-query "PII tables in Snowflake" -# Force OAuth even if API key is in .env -uv run atlan_cli.py --oauth call-tool semantic_search_tool --user-query "PII tables" +# Traverse lineage +atlan --oauth traverse_lineage_tool --guid "abc-123" --direction DOWNSTREAM -# Override auth via env var -ATLAN_AUTH=oauth uv run atlan_cli.py call-tool semantic_search_tool --user-query "PII tables" +# Get a specific asset +atlan --oauth get_asset_tool --guid "abc-123" ``` -## Available Capabilities +> **Note:** `--oauth` must come before the tool name (it's a global flag, not a tool argument). -- **Search & discovery** — natural language search, structured filters, lineage traversal -- **Asset updates** — descriptions, owners, certificates, tags, announcements -- **Glossary** — create and manage glossaries, terms, and categories -- **Data governance** — domains and data products -- **Data quality** — create, update, schedule, and delete DQ rules -- **Custom metadata** — manage CM sets and attribute values +## Available Tools -Run `uv run atlan_cli.py list-tools` for the full list with parameter details. +- **Search & discovery** — `semantic_search_tool`, `search_assets_tool`, `traverse_lineage_tool` +- **Asset detail** — `get_asset_tool`, `resolve_metadata_tool` +- **Asset updates** — `update_assets_tool`, `manage_announcements_tool`, `manage_asset_lifecycle_tool` +- **Glossary** — `create_glossaries`, `create_glossary_terms`, `create_glossary_categories` +- **Data governance** — `create_domains`, `create_data_products` +- **Data quality** — `create_dq_rules_tool`, `update_dq_rules_tool`, `schedule_dq_rules_tool`, `delete_dq_rules_tool` +- **Custom metadata** — `update_custom_metadata_tool`, `remove_custom_metadata_tool`, `create_custom_metadata_set_tool` +- **Tags** — `add_atlan_tags_tool`, `remove_atlan_tag_tool` + +Run `atlan --help` to see all commands with their parameters. + +## Updating + +After pulling new changes: + +```bash +uv tool install /path/to/agent-toolkit/mcp-cli --reinstall +``` ## Regenerating the CLI -If tool schemas change, regenerate with: +If tool schemas change (new tools added to the server), regenerate with: ```bash fastmcp generate-cli https://your-tenant.atlan.com/mcp --auth oauth --output atlan_cli.py --force ``` -Then re-apply the auth block at the top of the file (everything above `app = cyclopts.App(...)`) — `fastmcp` does not write auth into generated scripts by design. +Then re-apply the auth/packaging block at the top (everything above `app = cyclopts.App(...)`) — `fastmcp` does not write auth into generated scripts by design. From 7d9c3dd9bfe48490bc6b9a4b753fdf8806c3bdf5 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 12:25:42 +0530 Subject: [PATCH 13/26] docs(mcp-cli): add improvement plan from team discussion Captures the agreed direction from the meeting with Ankit and Hrushikesh: persistent login state, hardcoded proxy URL, ~/.atlan config dir, PyPI distribution modeled on pyatlan, agent-friendly diagnostics, and a phased rollout aligned with the Activate demo. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/PLAN.md | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 mcp-cli/PLAN.md diff --git a/mcp-cli/PLAN.md b/mcp-cli/PLAN.md new file mode 100644 index 0000000..d40334a --- /dev/null +++ b/mcp-cli/PLAN.md @@ -0,0 +1,148 @@ +# Atlan MCP CLI — Improvement Plan + +Based on the meeting discussion (Abhinav, Ankit, Hrushikesh) and a review of how `pyatlan` is packaged. + +## Goals + +| Goal | What it means | +|------|---------------| +| **One-command install** | `uv tool install atlan-mcp-cli` (PyPI), no clone-and-install dance | +| **No `--oauth` repetition** | `atlan login` once → all subsequent calls authenticated automatically | +| **No URL configuration** | Default endpoint hardcoded to `https://mcp.atlan.com/mcp` (proxy) | +| **Agent-friendly diagnostics** | `atlan status` returns clear "ready" vs. "missing X" so agents can self-correct | +| **Zero-edit schema sync** | Regenerating against new MCP schema is a single make target, not manual paste | + +## What changes (in priority order) + +### 1. `atlan login` / `atlan logout` / `atlan status` + +Replace the `--oauth` flag with persistent login state. + +```bash +atlan login # interactive: prompts for OAuth (default) or API key +atlan login --oauth # force OAuth, non-interactive +atlan login --api-key # set API key non-interactively (for agents/CI) +atlan logout # clear stored credentials and tokens +atlan status # diagnostic: prints auth state + tenant + token expiry +``` + +**Behavior:** +- `login` writes auth mode to `~/.atlan/config.json` (`{"auth": "oauth"}` or `{"auth": "api-key"}`). +- For OAuth, opens browser and stores token in OS keychain (existing flow). +- For API key, stores in keychain with service `atlan-cli/api-key`. +- `status` returns: + - ✅ `Ready — authenticated as via , expires in ` + - ❌ `Not authenticated — run 'atlan login' to set up` + - ❌ `Token expired — run 'atlan login' to refresh` (with exit code 2 so agents detect it) +- All tool calls auto-route based on stored auth — no `--oauth` flag needed at call time. + +### 2. Hardcode the proxy endpoint + +```python +DEFAULT_BASE_URL = "https://mcp.atlan.com" +``` + +- Drop the requirement to set `ATLAN_BASE_URL` in normal use. +- Keep `ATLAN_BASE_URL` env-var override for staging/dev tenants only (documented but not surfaced). +- Tenant identity comes from the OAuth token (issuer claim) or API key (decoded), not from a user-supplied URL. + +### 3. `~/.atlan/` config directory + +Mirror the `~/.claude/` and `~/.cursor/` pattern for inspectability. + +``` +~/.atlan/ +├── config.json # {"auth": "oauth", "default_tenant": "..."} +└── logs/ # optional: tool call audit log (off by default) +``` + +- Tokens stay in OS keychain (Mac Keychain, Windows Credential Manager, Linux Secret Service via `keyring`). +- Fallback `~/.atlan/credentials.json` (chmod 600) for systems without a keychain (CI, headless Linux). +- Easy to inspect when debugging customer issues — Hrushikesh's point about `claude_desktop_config.json` style. + +### 4. PyPI distribution (`uv tool install atlan-mcp-cli`) + +Modeled on pyatlan's pipeline. + +**Steps:** +1. Add `pyatlan-style` `pyproject.toml` with proper metadata (authors, classifiers, urls, license). +2. Use dynamic version from a `version.txt` file in the package. +3. Add `.github/workflows/mcp-cli-publish.yaml` triggered on `release: published` for tags matching `mcp-cli-v*`. + - Uses `astral-sh/setup-uv@v7`, runs `uv build` + `uv publish` with `PYPI_API_TOKEN`. +4. Reserve the package name `atlan-mcp-cli` on PyPI. +5. Final user experience: + ```bash + uv tool install atlan-mcp-cli + atlan login + atlan semantic_search_tool --user-query "PII tables" + ``` + +### 5. Agent-friendly output mode + +- Default human output: rich tables / formatted JSON. +- `--json` flag (or `ATLAN_OUTPUT=json` env var): emits raw JSON to stdout, all logs to stderr — clean for agent parsing. +- Exit codes: + - `0` success + - `1` tool error (server returned an error) + - `2` auth error (run `atlan login`) + - `3` config / validation error +- Optionally write last result to `~/.atlan/last_result.json` for resumability (Hrushikesh's "temp file" idea — opt-in via `--save-last`). + +### 6. Schema regeneration via Makefile + +Currently the README has a manual paste step. Replace with: + +```makefile +regenerate: + fastmcp generate-cli https://mcp.atlan.com/mcp --auth oauth --output _generated.py --force + python _merge_auth.py _generated.py atlan_cli.py # script that re-applies our auth/main block + rm _generated.py +``` + +A small `_merge_auth.py` script keeps the top-of-file block (imports, auth, `_resolve_auth`, `_client`, `main`) intact while replacing the tool-command section. Removes the "remember to paste auth back" footgun. + +### 7. Beautified CLI surface + +- Group commands in `--help` output by category (Search, Update, Glossary, DQ, etc.) using cyclopts groups. +- Add brief examples in each tool's help text (one-liner showing typical usage). +- Color/icon polish using `rich` (already a dep): green for success, red for errors, dim for metadata. +- For `list-tools`, add a `--category` filter (e.g., `atlan list-tools --category glossary`). + +### 8. Validation / preflight + +`atlan status` runs a real ping: +1. Reads config + credentials. +2. Hits `mcp.atlan.com/mcp` `initialize` to confirm the token is valid. +3. Reports tenant, user, token expiry, and which auth mode was used. +4. If anything fails, prints exactly which step failed and the fix. + +### 9. Cleanups (current PR carryovers) + +- Stop tracking `atlan-claude-plugin.tar.gz` and `.zip` build artifacts (add to `.gitignore`). +- Remove `mcp-cli/.env`, `mcp-cli/.venv`, `mcp-cli/build/`, `mcp-cli/atlan_mcp_cli.egg-info/`, `mcp-cli/uv.lock` from git — these are local artifacts from `uv tool install`. + +## Out of scope (for now) + +- A separate "atlan agent toolkit" higher-level wrapper — the CLI stays a thin transport over MCP, agents handle reasoning. +- Renaming MCP tools — they keep their server-side names. If we want shorter names, change them in MCP first, then regenerate. +- Beautifying the OAuth callback page (the FastMCP default page) — not blocking; can be polished later when we move to atlanhq-branded callback HTML. + +## Phasing + +| Phase | Items | Why first | +|-------|-------|-----------| +| **P0 (Activate demo)** | 1 (`login`/`status`), 2 (hardcoded proxy), 3 (`~/.atlan/`), 7 (basic polish), 9 (cleanups) | Demo tonight needs `atlan login` → tool calls to "just work" | +| **P1 (next sprint)** | 4 (PyPI publish), 5 (`--json` flag), 8 (real preflight) | Once we've validated the UX, ship to PyPI so any agent author can `uv tool install atlan-mcp-cli` | +| **P2** | 6 (Makefile regen), `--category` filtering, error message polish | Maintenance layer — once the core is shipped | + +## Open questions + +- **Package name on PyPI**: `atlan-mcp-cli`, `atlan-cli`, or `atlan`? `atlan` may collide with future official Atlan CLI; `atlan-mcp-cli` is most explicit but verbose. Recommend `atlan-cli` and own the namespace. +- **Token refresh**: do we silently refresh on expiry, or always require explicit `atlan login`? The OAuth provider supports refresh tokens — let's silently refresh and only prompt if the refresh token itself is invalid. +- **Multi-tenant**: does an end-user need to support multiple tenants (`atlan login --tenant prod`, `atlan --tenant prod search …`)? Not in the demo, but worth designing the config schema to allow it. + +## References + +- pyatlan packaging: `/Users/abhinav.mathur/atlan-repos/atlan-python/pyproject.toml`, `pyatlan-publish.yaml` +- Current CLI: `/Users/abhinav.mathur/atlan-repos/agent-toolkit/mcp-cli/atlan_cli.py` (branch `feat/mcp-cli`) +- Meeting transcript with Ankit, Hrushikesh, Abhinav From 75f546016dc87defeab8341f03b40002b6a7c2c6 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 12:35:28 +0530 Subject: [PATCH 14/26] docs(mcp-cli): expand plan with refresh-token reuse and full P0-P2 design Incorporates findings from agent-toolkit-internal:mcp_proxy: the proxy already supports refresh_token grant and derives tenant from the JWT issuer claim, so the CLI can store only the refresh token and never needs to know the tenant URL. - Detailed file layout (src/atlan_cli/auth, commands, transport) - Auth resolver pseudo-code with stale-cache wipe invariants - Refresh-token flow against mcp.atlan.com/oauth/token - Decision: keep cyclopts, add rich/questionary for prettier output - Phased breakdown with effort estimates per item - GitHub Actions publish workflow modeled on pyatlan-publish.yaml - Test plan, risks, references Co-Authored-By: Claude Sonnet 4.6 (1M context) --- mcp-cli/PLAN.md | 554 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 451 insertions(+), 103 deletions(-) diff --git a/mcp-cli/PLAN.md b/mcp-cli/PLAN.md index d40334a..b163fd2 100644 --- a/mcp-cli/PLAN.md +++ b/mcp-cli/PLAN.md @@ -1,148 +1,496 @@ -# Atlan MCP CLI — Improvement Plan +# Atlan MCP CLI — Detailed Implementation Plan -Based on the meeting discussion (Abhinav, Ankit, Hrushikesh) and a review of how `pyatlan` is packaged. +Based on the meeting with Ankit & Hrushikesh, a review of `pyatlan` packaging, and the `mcp_proxy` branch on `agent-toolkit-internal`. -## Goals +--- -| Goal | What it means | -|------|---------------| -| **One-command install** | `uv tool install atlan-mcp-cli` (PyPI), no clone-and-install dance | -| **No `--oauth` repetition** | `atlan login` once → all subsequent calls authenticated automatically | -| **No URL configuration** | Default endpoint hardcoded to `https://mcp.atlan.com/mcp` (proxy) | -| **Agent-friendly diagnostics** | `atlan status` returns clear "ready" vs. "missing X" so agents can self-correct | -| **Zero-edit schema sync** | Regenerating against new MCP schema is a single make target, not manual paste | +## 0. Key insight from the `mcp_proxy` branch -## What changes (in priority order) +The `oauth_proxy/app.py` on `agent-toolkit-internal:mcp_proxy` already implements the proxy at `mcp.atlan.com/oauth/*`: -### 1. `atlan login` / `atlan logout` / `atlan status` +| Endpoint | Purpose | Notes | +|----------|---------|-------| +| `GET /.well-known/oauth-authorization-server` | OASM discovery | Advertises `grant_types_supported: ["authorization_code", "refresh_token"]` | +| `POST /oauth/register` | Dynamic Client Registration | Returns `client_id=mcp-client`, `client_secret=placeholder` | +| `POST /oauth/token` | Token endpoint | Handles **both** `authorization_code` AND `refresh_token` grants | -Replace the `--oauth` flag with persistent login state. +The `extract_tenant_from_refresh_token()` function (line 219) decodes the refresh token JWT and reads the `iss` claim → tenant is fully encoded in the refresh token, so **the CLI never needs to know which tenant it talks to** beyond initial login. + +**This means:** +- We can store **only the refresh token** in the keyring (long-lived, ~30 days). +- Each command gets a fresh access token via the refresh grant, with no browser interaction. +- The tenant URL never appears in our config — the proxy handles it. + +--- + +## 1. End-state UX ```bash -atlan login # interactive: prompts for OAuth (default) or API key -atlan login --oauth # force OAuth, non-interactive -atlan login --api-key # set API key non-interactively (for agents/CI) -atlan logout # clear stored credentials and tokens -atlan status # diagnostic: prints auth state + tenant + token expiry +# One-time setup (interactive prompt picks oauth or api-key) +atlan login + +# Or non-interactively +atlan login --oauth +atlan login --api-key sk-xxx +atlan login --api-key sk-xxx --tenant https://demo.atlan.com # api-key only + +# Daily use — no flags needed +atlan semantic_search_tool --user-query "PII tables" +atlan list-tools +atlan get_asset_tool --guid abc-123 + +# Diagnostics +atlan status # who am I, mode, expiry, tenant +atlan logout # wipe everything + +# Power-user overrides (still supported) +atlan --oauth semantic_search_tool ... # force fresh OAuth this call +atlan --api-key sk-xxx semantic_search_tool ... # one-shot api key ``` -**Behavior:** -- `login` writes auth mode to `~/.atlan/config.json` (`{"auth": "oauth"}` or `{"auth": "api-key"}`). -- For OAuth, opens browser and stores token in OS keychain (existing flow). -- For API key, stores in keychain with service `atlan-cli/api-key`. -- `status` returns: - - ✅ `Ready — authenticated as via , expires in ` - - ❌ `Not authenticated — run 'atlan login' to set up` - - ❌ `Token expired — run 'atlan login' to refresh` (with exit code 2 so agents detect it) -- All tool calls auto-route based on stored auth — no `--oauth` flag needed at call time. +Exit codes (agent-friendly): +- `0` success +- `1` server returned a tool error +- `2` not authenticated / token expired and refresh failed → `atlan login` +- `3` config error (invalid api-key shape, missing tenant for api-key, etc.) + +--- + +## 2. CLI Library Decision + +**Stay with `cyclopts`** — switching mid-stream is churn for marginal gain. + +| Library | Pros | Cons | Verdict | +|---------|------|------|---------| +| **cyclopts** (current) | Type-hint first, native async, already wired up, `rich`-powered help | Less mindshare than click | **Keep** — meets every requirement | +| typer | Big ecosystem | Click underneath; sync-only ergonomics for async commands | No upside for our case | +| click | Battle-tested | Verbose decorators, pre-typing era | Step backwards | +| rich-click | Pretty click | Locked into click | Same downsides as click | + +**What we add for "prettier" without switching libraries:** +- `rich.console.Console` (already imported) for tables, panels, syntax-highlighted JSON +- `rich.progress` for long tool calls +- `questionary` for `atlan login` interactive prompt (`Choose auth method ▸`) +- Cyclopts command groups for category-grouped help (Search / Update / Glossary / DQ) + +--- + +## 3. Architecture + +### 3.1 File layout + +``` +mcp-cli/ +├── pyproject.toml # PyPI metadata, dynamic version from version.txt +├── version.txt # single source of truth for version +├── README.md +├── PLAN.md # this file (drop after rollout) +├── Makefile # regenerate, build, publish targets +├── src/ +│ └── atlan_cli/ +│ ├── __init__.py # __version__ from importlib.metadata +│ ├── __main__.py # python -m atlan_cli +│ ├── app.py # cyclopts.App + main() entry +│ ├── auth/ +│ │ ├── __init__.py +│ │ ├── config.py # ~/.atlan/config.json read/write +│ │ ├── keyring_store.py # access/refresh token storage in OS keychain +│ │ ├── oauth.py # browser flow + refresh-token reuse +│ │ ├── api_key.py # api-key validation/storage +│ │ └── resolver.py # `_resolve_auth()` — picks mode, refreshes if needed +│ ├── commands/ +│ │ ├── login.py # atlan login / logout / status +│ │ └── tools/ +│ │ └── _generated.py # output of `fastmcp generate-cli` — tool commands +│ ├── transport.py # Client(...) wrapper, exit-code mapping +│ └── output.py # rich formatters, --json mode, exit codes +├── scripts/ +│ └── merge_generated.py # post-process fastmcp's output, re-apply our top block +└── .github/workflows/ + └── mcp-cli-publish.yaml # PyPI publish on tag mcp-cli-v* +``` + +### 3.2 Config / credentials storage + +| File | Contents | Permissions | +|------|----------|-------------| +| `~/.atlan/config.json` | `{"auth_mode": "oauth"\|"api-key", "tenant": "https://..." (api-key only), "client_id": "mcp-client"}` | 0644 | +| OS keyring `atlan-mcp/access_token` | Short-lived JWT (5–60 min) | keychain | +| OS keyring `atlan-mcp/refresh_token` | Long-lived JWT (~30 days) | keychain | +| OS keyring `atlan-mcp/api_key` | Atlan API key (only when in api-key mode) | keychain | +| `~/.atlan/credentials.json` (fallback for headless) | `{"refresh_token": "...", "access_token": "...", "expires_at": }` | **0600** | + +Resolution order on each command: +1. Read `~/.atlan/config.json` → know which mode. +2. **api-key mode**: pull api-key from keyring → done. +3. **oauth mode**: + - Pull access_token. If `now + 30s < expires_at`, use it. + - Else pull refresh_token, hit `https://mcp.atlan.com/oauth/token` with `grant_type=refresh_token`, get new access+refresh, store both. + - If refresh fails → wipe keyring entries (stale cache cleanup) and exit `2` with "Session expired — run `atlan login`". -### 2. Hardcode the proxy endpoint +### 3.3 Auth resolver pseudo-code ```python -DEFAULT_BASE_URL = "https://mcp.atlan.com" +@dataclass +class ResolvedAuth: + client_spec: str # full MCP URL + auth: httpx.Auth # BearerAuth or pre-fetched Bearer + mode: Literal["oauth", "api-key"] + +async def resolve_auth(*, force_oauth: bool = False, override_api_key: str | None = None) -> ResolvedAuth: + # 1. Per-call overrides win + if override_api_key: + return ResolvedAuth(MCP_URL_API_KEY, BearerAuth(override_api_key), "api-key") + if force_oauth: + return await _do_full_oauth_login(persist=False) + + # 2. Read persisted config + cfg = load_config() + if cfg is None: + raise NotAuthenticated("Run `atlan login` to set up credentials") + + if cfg.auth_mode == "api-key": + api_key = keyring.get("atlan-mcp", "api_key") + if not api_key: + wipe_credentials() + raise NotAuthenticated("API key missing from keychain — run `atlan login`") + return ResolvedAuth(f"{cfg.tenant}/mcp/api-key", BearerAuth(api_key), "api-key") + + # oauth mode + access = keyring.get("atlan-mcp", "access_token_json") + if access and not _expired(access): + return ResolvedAuth(MCP_URL, BearerAuth(access["access_token"]), "oauth") + + # Refresh + refresh = keyring.get("atlan-mcp", "refresh_token") + if not refresh: + wipe_credentials() + raise NotAuthenticated("No refresh token — run `atlan login`") + try: + new = await _refresh(refresh) # POST mcp.atlan.com/oauth/token + keyring.set("atlan-mcp", "access_token_json", new) + if new.get("refresh_token"): # refresh-token rotation + keyring.set("atlan-mcp", "refresh_token", new["refresh_token"]) + return ResolvedAuth(MCP_URL, BearerAuth(new["access_token"]), "oauth") + except RefreshFailed: + wipe_credentials() # mandatory stale-cache wipe + raise NotAuthenticated("Session expired — run `atlan login`") ``` -- Drop the requirement to set `ATLAN_BASE_URL` in normal use. -- Keep `ATLAN_BASE_URL` env-var override for staging/dev tenants only (documented but not surfaced). -- Tenant identity comes from the OAuth token (issuer claim) or API key (decoded), not from a user-supplied URL. +### 3.4 `atlan login` flow -### 3. `~/.atlan/` config directory +``` +$ atlan login +? How do you want to authenticate? + ▸ OAuth (browser) + API key +[OAuth selected] +✓ Opening browser… + → https://atlan-demo.atlan.com/auth/realms/default/protocol/openid-connect/auth?… +✓ Token received and stored in OS keychain. +✓ You are logged in as abhinav.mathur@atlan.com + Tenant: atlan-demo.atlan.com + Token expires in: 5m (auto-refresh enabled) +``` -Mirror the `~/.claude/` and `~/.cursor/` pattern for inspectability. +For api-key: ``` -~/.atlan/ -├── config.json # {"auth": "oauth", "default_tenant": "..."} -└── logs/ # optional: tool call audit log (off by default) +$ atlan login --api-key sk-xxx +? Tenant URL: https://atlan-demo.atlan.com +✓ Validated key against /api/auth/whoami → user: abhinav.mathur +✓ Stored in OS keychain. ``` -- Tokens stay in OS keychain (Mac Keychain, Windows Credential Manager, Linux Secret Service via `keyring`). -- Fallback `~/.atlan/credentials.json` (chmod 600) for systems without a keychain (CI, headless Linux). -- Easy to inspect when debugging customer issues — Hrushikesh's point about `claude_desktop_config.json` style. +`atlan logout` deletes config.json + all keyring entries. -### 4. PyPI distribution (`uv tool install atlan-mcp-cli`) +`atlan status`: +``` +✓ Authenticated via OAuth + User: abhinav.mathur@atlan.com + Tenant: atlan-demo.atlan.com + Access token expires in: 8m 32s (auto-refresh on next call) + Refresh token expires in: 29d 17h +``` -Modeled on pyatlan's pipeline. +When unauthenticated, status exits `2` with a single-line "Not authenticated. Run `atlan login`." — agents read stdout and act. -**Steps:** -1. Add `pyatlan-style` `pyproject.toml` with proper metadata (authors, classifiers, urls, license). -2. Use dynamic version from a `version.txt` file in the package. -3. Add `.github/workflows/mcp-cli-publish.yaml` triggered on `release: published` for tags matching `mcp-cli-v*`. - - Uses `astral-sh/setup-uv@v7`, runs `uv build` + `uv publish` with `PYPI_API_TOKEN`. -4. Reserve the package name `atlan-mcp-cli` on PyPI. -5. Final user experience: - ```bash - uv tool install atlan-mcp-cli - atlan login - atlan semantic_search_tool --user-query "PII tables" - ``` +--- -### 5. Agent-friendly output mode +## 4. Phasing -- Default human output: rich tables / formatted JSON. -- `--json` flag (or `ATLAN_OUTPUT=json` env var): emits raw JSON to stdout, all logs to stderr — clean for agent parsing. -- Exit codes: - - `0` success - - `1` tool error (server returned an error) - - `2` auth error (run `atlan login`) - - `3` config / validation error -- Optionally write last result to `~/.atlan/last_result.json` for resumability (Hrushikesh's "temp file" idea — opt-in via `--save-last`). +### **P0 — Activate demo-ready (today + tonight)** -### 6. Schema regeneration via Makefile +| # | Item | Files touched | Effort | +|---|------|---------------|--------| +| 1 | Hardcode proxy URL as default (`https://mcp.atlan.com/mcp`) | `atlan_cli.py` | 10 min | +| 2 | Migrate to `~/.atlan/config.json` for auth mode + `keyring` for tokens | new `auth/` module | 1.5 h | +| 3 | `atlan login` (interactive + `--oauth` + `--api-key`) | `commands/login.py` | 2 h | +| 4 | `atlan logout`, `atlan status` | `commands/login.py` | 30 min | +| 5 | Auto-refresh access token via refresh-token grant; wipe on failure | `auth/oauth.py` | 1 h | +| 6 | Drop `--oauth` requirement from per-tool calls — flag becomes per-call override | `app.py`, `transport.py` | 30 min | +| 7 | Cleanups: `.gitignore` build artifacts, drop tracked `.env`, `.venv`, `uv.lock` from git | `.gitignore`, `git rm --cached` | 15 min | +| 8 | Preserve current `pyproject.toml` (entry point `atlan = atlan_cli.app:main`) | `pyproject.toml` | 10 min | -Currently the README has a manual paste step. Replace with: +P0 deliverable: -```makefile -regenerate: - fastmcp generate-cli https://mcp.atlan.com/mcp --auth oauth --output _generated.py --force - python _merge_auth.py _generated.py atlan_cli.py # script that re-applies our auth/main block - rm _generated.py +```bash +uv tool install /path/to/agent-toolkit/mcp-cli +atlan login # browser opens, log in once +atlan semantic_search_tool --user-query x # works, no flags, no env vars +atlan status # shows auth state +``` + +### **P1 — Polish + PyPI (next sprint)** + +| # | Item | Files | Effort | +|---|------|-------|--------| +| 9 | Pretty output: rich tables for `list-tools`, syntax-highlighted JSON for tool results | `output.py` | 2 h | +| 10 | `--json` flag + `ATLAN_OUTPUT=json` for clean agent parsing | `output.py`, all commands | 1 h | +| 11 | `--save-last` writes `~/.atlan/last_result.json` (Hrushikesh's idea — opt-in) | `output.py` | 30 min | +| 12 | Cyclopts groups in `--help` (Search / Update / Glossary / DQ / Custom Metadata / Tags) | `app.py` | 1 h | +| 13 | `version.txt` + dynamic version + `__version__` exposed | `pyproject.toml`, `__init__.py` | 30 min | +| 14 | GitHub Actions publish workflow (`.github/workflows/mcp-cli-publish.yaml`) — modeled exactly on `pyatlan-publish.yaml` | new file | 1 h | +| 15 | Reserve `atlan-cli` on PyPI; first release | release process | 30 min | +| 16 | `atlan login --api-key` validates against `/api/meta/whoami` before storing | `auth/api_key.py` | 30 min | + +P1 deliverable: `uv tool install atlan-cli` from PyPI works for any agent author. + +### **P2 — Maintenance + nice-to-haves** + +| # | Item | Files | Effort | +|---|------|-------|--------| +| 17 | `Makefile` target for `regenerate` (fastmcp generate-cli + merge script) | `Makefile`, `scripts/merge_generated.py` | 2 h | +| 18 | `atlan list-tools --category glossary` filtering | `commands/tools/list_tools.py` | 30 min | +| 19 | Structured error messages — distinguish auth, network, schema, server | `transport.py` | 1 h | +| 20 | `atlan login --tenant ` for multi-tenant config (`~/.atlan/profiles/.json`); `atlan --profile ...` | `auth/config.py` | 3 h | +| 21 | Token refresh attempted **inside** the request flow (not just on each call boundary) — handles long-running batch where token expires mid-loop | `transport.py` | 1 h | +| 22 | Headless mode: print device-flow URL + code instead of opening browser when `DISPLAY` not set | `auth/oauth.py` | 1 h | +| 23 | `atlan completion zsh|bash|fish` for shell tab-completion | `commands/completion.py` | 1 h | +| 24 | Telemetry opt-in (`atlan login --telemetry on`) — count tool calls, errors, latency | `output.py` | 2 h | + +--- + +## 5. Detailed implementation notes + +### 5.1 Refresh-token flow (P0 #5) + +```python +import httpx, time + +PROXY_TOKEN_ENDPOINT = "https://mcp.atlan.com/oauth/token" + +async def _refresh(refresh_token: str) -> dict: + """Exchange refresh token for new access+refresh. + + Server (mcp_proxy/app.py:646) decodes the refresh token, derives the + tenant from its `iss` claim, and forwards to that tenant's Keycloak. + Returns: {access_token, refresh_token, expires_in, token_type, ...} + """ + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + PROXY_TOKEN_ENDPOINT, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": "mcp-client", + "client_secret": "placeholder", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code != 200: + raise RefreshFailed(f"{resp.status_code}: {resp.text[:200]}") + body = resp.json() + body["expires_at"] = int(time.time()) + body.get("expires_in", 300) - 30 # 30s safety + return body ``` -A small `_merge_auth.py` script keeps the top-of-file block (imports, auth, `_resolve_auth`, `_client`, `main`) intact while replacing the tool-command section. Removes the "remember to paste auth back" footgun. +Storing the result: +- Store `{"access_token": ..., "expires_at": ts}` JSON-encoded under `atlan-mcp/access_token_json`. +- Store the (possibly rotated) refresh token under `atlan-mcp/refresh_token`. +- On `RefreshFailed`, call `wipe_credentials()` immediately so the next call starts fresh — this is the "always remove stale cache" requirement. + +### 5.2 Stale cache invariant (P0 #5) -### 7. Beautified CLI surface +`wipe_credentials()` is called from exactly three places: +1. Refresh fails (server says `invalid_grant` → refresh token revoked). +2. `atlan logout`. +3. `atlan login` (always wipes before writing new state — prevents mode-switch cross-contamination). -- Group commands in `--help` output by category (Search, Update, Glossary, DQ, etc.) using cyclopts groups. -- Add brief examples in each tool's help text (one-liner showing typical usage). -- Color/icon polish using `rich` (already a dep): green for success, red for errors, dim for metadata. -- For `list-tools`, add a `--category` filter (e.g., `atlan list-tools --category glossary`). +Never call `wipe_credentials()` from any other path; otherwise `atlan login` followed by transient network failures could surprise the user with re-prompt loops. -### 8. Validation / preflight +### 5.3 `--oauth` and `--api-key` as overrides (P0 #6) -`atlan status` runs a real ping: -1. Reads config + credentials. -2. Hits `mcp.atlan.com/mcp` `initialize` to confirm the token is valid. -3. Reports tenant, user, token expiry, and which auth mode was used. -4. If anything fails, prints exactly which step failed and the fix. +Make them root-level cyclopts parameters (not subcommand args). Cyclopts allows global flags via `app.meta.command()`: -### 9. Cleanups (current PR carryovers) +```python +app = cyclopts.App(name="atlan", help="Atlan MCP CLI") + +# Global override flags consumed in main() before cyclopts parses subcommand +def main() -> None: + args = sys.argv[1:] + overrides = {} + if "--oauth" in args: + args.remove("--oauth") + overrides["force_oauth"] = True + for i, a in enumerate(args): + if a == "--api-key": + overrides["api_key"] = args[i + 1] + del args[i:i + 2] + break + if a.startswith("--api-key="): + overrides["api_key"] = a.split("=", 1)[1] + del args[i] + break + set_overrides(overrides) # module-level, picked up by resolve_auth + sys.argv[1:] = args + app() +``` + +This keeps the per-call override functional without polluting every tool's signature. + +### 5.4 Pretty output (P1 #9) + +```python +from rich.table import Table +from rich.syntax import Syntax +from rich.console import Console + +console = Console() + +def render_tool_result(content) -> None: + if _is_json(content): + if OUTPUT_MODE == "json": + print(json.dumps(content)) # raw, agent-friendly + return + console.print(Syntax(json.dumps(content, indent=2), "json", theme="monokai")) + else: + console.print(content) +``` + +For `list-tools`: + +```python +table = Table(title="Atlan MCP Tools", show_lines=True) +table.add_column("Category", style="cyan") +table.add_column("Tool") +table.add_column("Description", overflow="fold") +for tool in tools: + table.add_row(_categorize(tool.name), tool.name, tool.description) +console.print(table) +``` + +### 5.5 Cleanups (P0 #7) + +`.gitignore` additions: +``` +mcp-cli/.env +mcp-cli/.venv/ +mcp-cli/build/ +mcp-cli/dist/ +mcp-cli/*.egg-info/ +mcp-cli/uv.lock # debatable — pyatlan tracks it; for a tool, prefer not +*.tar.gz +*.zip +``` + +`git rm --cached`: +``` +mcp-cli/.env +mcp-cli/uv.lock +mcp-cli/atlan_mcp_cli.egg-info/ +mcp-cli/build/ +atlan-claude-plugin.tar.gz +atlan-claude-plugin.zip +``` + +### 5.6 Schema regeneration (P2 #17) + +`scripts/merge_generated.py`: +1. Reads our `commands/tools/_generated.py` to find the line `# === GENERATED TOOL COMMANDS BELOW ===`. +2. Reads fresh fastmcp output, finds the same marker. +3. Replaces everything below the marker, preserves everything above. + +`Makefile`: +```makefile +.PHONY: regenerate build publish test + +regenerate: + fastmcp generate-cli https://mcp.atlan.com/mcp --auth oauth --output /tmp/atlan_generated.py --force + python scripts/merge_generated.py /tmp/atlan_generated.py src/atlan_cli/commands/tools/_generated.py + rm /tmp/atlan_generated.py + +build: + uv build + +publish: build + uv publish + +test: + pytest tests/ +``` + +### 5.7 GitHub Actions publish workflow (P1 #14) + +Lifted directly from `atlan-python/.github/workflows/pyatlan-publish.yaml`: + +```yaml +name: Publish atlan-cli to PyPI +on: + release: + types: [published] + workflow_dispatch: + +jobs: + deploy: + if: success() && startsWith(github.ref, 'refs/tags/mcp-cli-v') + runs-on: ubuntu-latest + permissions: { contents: read } + defaults: { run: { working-directory: mcp-cli } } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: '3.10' } + - uses: astral-sh/setup-uv@v7 + - run: uv sync --group dev + - run: uv run python scripts/check_tag.py + - env: { UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} } + run: | + uv build + uv publish +``` -- Stop tracking `atlan-claude-plugin.tar.gz` and `.zip` build artifacts (add to `.gitignore`). -- Remove `mcp-cli/.env`, `mcp-cli/.venv`, `mcp-cli/build/`, `mcp-cli/atlan_mcp_cli.egg-info/`, `mcp-cli/uv.lock` from git — these are local artifacts from `uv tool install`. +`scripts/check_tag.py` mirrors pyatlan's — refuses to publish if `version.txt` doesn't match the git tag. -## Out of scope (for now) +### 5.8 Test plan -- A separate "atlan agent toolkit" higher-level wrapper — the CLI stays a thin transport over MCP, agents handle reasoning. -- Renaming MCP tools — they keep their server-side names. If we want shorter names, change them in MCP first, then regenerate. -- Beautifying the OAuth callback page (the FastMCP default page) — not blocking; can be polished later when we move to atlanhq-branded callback HTML. +| Layer | Tests | +|-------|-------| +| `auth/resolver.py` | unit: api-key happy path, oauth happy path, expired access → refresh, refresh fail → wipe | +| `auth/keyring_store.py` | unit: round-trip, missing entry returns None | +| `auth/oauth.py` | mocked httpx — refresh request shape, browser flow stubbed | +| `commands/login.py` | integration: against a local fake proxy serving `/oauth/token` | +| `transport.py` | integration: real `mcp.atlan.com` with a test api-key (CI secret) | +| `output.py` | snapshot tests for rich rendering, --json mode | -## Phasing +--- -| Phase | Items | Why first | -|-------|-------|-----------| -| **P0 (Activate demo)** | 1 (`login`/`status`), 2 (hardcoded proxy), 3 (`~/.atlan/`), 7 (basic polish), 9 (cleanups) | Demo tonight needs `atlan login` → tool calls to "just work" | -| **P1 (next sprint)** | 4 (PyPI publish), 5 (`--json` flag), 8 (real preflight) | Once we've validated the UX, ship to PyPI so any agent author can `uv tool install atlan-mcp-cli` | -| **P2** | 6 (Makefile regen), `--category` filtering, error message polish | Maintenance layer — once the core is shipped | +## 6. Risks & mitigations -## Open questions +| Risk | Mitigation | +|------|------------| +| PyPI name `atlan-cli` taken | Fall back to `atlan-mcp-cli`. Squat both for safety. | +| Refresh token revoked by Keycloak admin | Caught by `RefreshFailed` → wipe → user re-`login`. Already handled. | +| `mcp_proxy` branch not yet on prod `mcp.atlan.com` | Verify on Apr 30 cutover; until then, make `PROXY_BASE_URL` configurable (`ATLAN_PROXY_URL` env) for testing. | +| Keychain unavailable in headless Docker / CI | Fallback file `~/.atlan/credentials.json` chmod 600 — already in design. | +| User runs `atlan` and `atlan-cli` separately and gets confused | Single PyPI package, single entry point. | -- **Package name on PyPI**: `atlan-mcp-cli`, `atlan-cli`, or `atlan`? `atlan` may collide with future official Atlan CLI; `atlan-mcp-cli` is most explicit but verbose. Recommend `atlan-cli` and own the namespace. -- **Token refresh**: do we silently refresh on expiry, or always require explicit `atlan login`? The OAuth provider supports refresh tokens — let's silently refresh and only prompt if the refresh token itself is invalid. -- **Multi-tenant**: does an end-user need to support multiple tenants (`atlan login --tenant prod`, `atlan --tenant prod search …`)? Not in the demo, but worth designing the config schema to allow it. +--- -## References +## 7. References -- pyatlan packaging: `/Users/abhinav.mathur/atlan-repos/atlan-python/pyproject.toml`, `pyatlan-publish.yaml` -- Current CLI: `/Users/abhinav.mathur/atlan-repos/agent-toolkit/mcp-cli/atlan_cli.py` (branch `feat/mcp-cli`) -- Meeting transcript with Ankit, Hrushikesh, Abhinav +- pyatlan packaging: `/Users/abhinav.mathur/atlan-repos/atlan-python/{pyproject.toml,check_tag.py,.github/workflows/pyatlan-publish.yaml}` +- MCP proxy refresh-token impl: `agent-toolkit-internal:mcp_proxy:oauth_proxy/app.py` (lines 219, 646–684) +- OAuth client lib: `mcp.client.auth.OAuthClientProvider` (mcp 1.27) +- Cyclopts docs: https://cyclopts.readthedocs.io/ +- Meeting transcript: Ankit / Hrushikesh / Abhinav — paste in `mcp-cli/.notes/2026-04-28-meeting.md` if we want it tracked From 6a6a1a332246a84425e544291b857655e389f559 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 13:43:46 +0530 Subject: [PATCH 15/26] feat(mcp-cli): persistent login, refresh tokens, rich UI, PyPI publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - atlan login / logout / status commands with OS keyring storage (~/.atlan/config.json + keyring with ~/.atlan/credentials.json fallback) - OAuth refresh-token grant via mcp.atlan.com proxy; stale-cache wipe on failure; exit 2 so callers can detect auth-required vs tool errors - _resolve_auth() hierarchy: per-call override → stored config → refresh → exit 2; legacy ATLAN_BASE_URL env-var path preserved for backwards compat - Global flags --oauth / --api-key / --tenant / --json stripped in main() before cyclopts sees argv; --json routes all output to stdout, logs to stderr - Exit codes: 0 success, 1 tool error, 2 auth required, 3 config/invocation - Rich UI: list-tools as Table, status/login as Panels, questionary chooser - Package renamed atlan-cli (was atlan-mcp-cli); hatchling build backend; dynamic version from __version__; dependency version ranges pinned - .github/workflows/mcp-cli-publish.yaml: release tag → uv build + uv publish - API key validation switched from /api/meta/whoami to /api/meta/types/typedefs (whoami returns 500 on some tenants; typedefs is more reliable) - mcp-cli/.gitignore: excludes build artifacts, .env, .venv, uv.lock, .fastmcp-cache/ Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mcp-cli-publish.yaml | 36 ++ mcp-cli/.gitignore | 22 + mcp-cli/PLAN.md | 587 +++++++------------------ mcp-cli/README.md | 79 ++-- mcp-cli/atlan_cli.py | 499 +++++++++++++++++++-- mcp-cli/pyproject.toml | 47 +- 6 files changed, 756 insertions(+), 514 deletions(-) create mode 100644 .github/workflows/mcp-cli-publish.yaml create mode 100644 mcp-cli/.gitignore diff --git a/.github/workflows/mcp-cli-publish.yaml b/.github/workflows/mcp-cli-publish.yaml new file mode 100644 index 0000000..8e84202 --- /dev/null +++ b/.github/workflows/mcp-cli-publish.yaml @@ -0,0 +1,36 @@ +name: Publish atlan-cli to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp-cli + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Build + run: uv build + + - name: Publish to PyPI + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: uv publish diff --git a/mcp-cli/.gitignore b/mcp-cli/.gitignore new file mode 100644 index 0000000..a259e04 --- /dev/null +++ b/mcp-cli/.gitignore @@ -0,0 +1,22 @@ +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Env / secrets +.env +.env.* +!.env.example + +# uv +uv.lock + +# FastMCP OAuth cache +.fastmcp-cache/ diff --git a/mcp-cli/PLAN.md b/mcp-cli/PLAN.md index b163fd2..343d431 100644 --- a/mcp-cli/PLAN.md +++ b/mcp-cli/PLAN.md @@ -1,496 +1,239 @@ -# Atlan MCP CLI — Detailed Implementation Plan +# Atlan MCP CLI — Implementation Plan -Based on the meeting with Ankit & Hrushikesh, a review of `pyatlan` packaging, and the `mcp_proxy` branch on `agent-toolkit-internal`. +> **Status:** P0 in progress. P1/P2 deferred to future PRs. +> **Approach:** Lighter-touch — keep the generated single-file CLI working, extract only auth/runtime seams; defer package-layout churn to P1. --- -## 0. Key insight from the `mcp_proxy` branch +## 0. Verified facts (Apr 2026) -The `oauth_proxy/app.py` on `agent-toolkit-internal:mcp_proxy` already implements the proxy at `mcp.atlan.com/oauth/*`: - -| Endpoint | Purpose | Notes | -|----------|---------|-------| -| `GET /.well-known/oauth-authorization-server` | OASM discovery | Advertises `grant_types_supported: ["authorization_code", "refresh_token"]` | -| `POST /oauth/register` | Dynamic Client Registration | Returns `client_id=mcp-client`, `client_secret=placeholder` | -| `POST /oauth/token` | Token endpoint | Handles **both** `authorization_code` AND `refresh_token` grants | - -The `extract_tenant_from_refresh_token()` function (line 219) decodes the refresh token JWT and reads the `iss` claim → tenant is fully encoded in the refresh token, so **the CLI never needs to know which tenant it talks to** beyond initial login. - -**This means:** -- We can store **only the refresh token** in the keyring (long-lived, ~30 days). -- Each command gets a fresh access token via the refresh grant, with no browser interaction. -- The tenant URL never appears in our config — the proxy handles it. +- **`mcp.atlan.com` proxy is already deployed** with refresh-token support: + - `GET /.well-known/oauth-authorization-server` → `grant_types_supported: ["authorization_code", "refresh_token"]` + - `POST /oauth/token` accepts both grants and decodes the refresh-token JWT to derive tenant (`oauth_proxy/app.py:219`) + - `POST /oauth/register` returns static `client_id=mcp-client`, `client_secret=placeholder` +- **Refresh tokens carry tenant info** in their `iss` JWT claim — the CLI never needs to persist tenant URL for OAuth. +- The current generated CLI uses `cyclopts` + `fastmcp.Client` and works against `https://atlan-demo.atlan.com/mcp` (verified end-to-end with the OAuth flow). --- ## 1. End-state UX ```bash -# One-time setup (interactive prompt picks oauth or api-key) -atlan login +# One-time setup +atlan login # interactive: pick OAuth or API key +atlan login --oauth # force OAuth, non-interactive +atlan login --api-key sk-xxx --tenant https://demo.atlan.com # api-key mode -# Or non-interactively -atlan login --oauth -atlan login --api-key sk-xxx -atlan login --api-key sk-xxx --tenant https://demo.atlan.com # api-key only - -# Daily use — no flags needed +# Daily use — no flags, no env vars atlan semantic_search_tool --user-query "PII tables" atlan list-tools atlan get_asset_tool --guid abc-123 # Diagnostics -atlan status # who am I, mode, expiry, tenant -atlan logout # wipe everything +atlan status # auth mode, tenant, expiry +atlan logout # wipe everything -# Power-user overrides (still supported) -atlan --oauth semantic_search_tool ... # force fresh OAuth this call -atlan --api-key sk-xxx semantic_search_tool ... # one-shot api key +# Per-call overrides (still supported) +atlan --oauth semantic_search_tool ... # force fresh OAuth this call +atlan --api-key sk-xxx --tenant https://t.com ... # one-shot API key ``` -Exit codes (agent-friendly): -- `0` success -- `1` server returned a tool error -- `2` not authenticated / token expired and refresh failed → `atlan login` -- `3` config error (invalid api-key shape, missing tenant for api-key, etc.) +**Exit codes:** +| Code | Meaning | +|------|---------| +| 0 | success | +| 1 | server/tool returned error | +| 2 | auth required or refresh failed → `atlan login` | +| 3 | config / invocation error | --- -## 2. CLI Library Decision +## 2. Approach -**Stay with `cyclopts`** — switching mid-stream is churn for marginal gain. +**Keep the file shape for P0.** The generated `atlan_cli.py` stays a single file; we only add handwritten helpers for auth, config, and IO. No `src/atlan_cli/` rewrite until UX is validated. This minimizes risk and preserves the regeneration story. -| Library | Pros | Cons | Verdict | -|---------|------|------|---------| -| **cyclopts** (current) | Type-hint first, native async, already wired up, `rich`-powered help | Less mindshare than click | **Keep** — meets every requirement | -| typer | Big ecosystem | Click underneath; sync-only ergonomics for async commands | No upside for our case | -| click | Battle-tested | Verbose decorators, pre-typing era | Step backwards | -| rich-click | Pretty click | Locked into click | Same downsides as click | +**Stay with `cyclopts`.** Already working, type-hint native, async-friendly. For prettier output we add `rich` (already a dep) panels/tables and `questionary` for interactive `atlan login` prompts. No need to switch to click/typer. -**What we add for "prettier" without switching libraries:** -- `rich.console.Console` (already imported) for tables, panels, syntax-highlighted JSON -- `rich.progress` for long tool calls -- `questionary` for `atlan login` interactive prompt (`Choose auth method ▸`) -- Cyclopts command groups for category-grouped help (Search / Update / Glossary / DQ) +**Use the existing keychain library** (`keyring`) — already a dep. JSON-file fallback only when keychain unavailable. --- ## 3. Architecture -### 3.1 File layout +### 3.1 Storage layout -``` -mcp-cli/ -├── pyproject.toml # PyPI metadata, dynamic version from version.txt -├── version.txt # single source of truth for version -├── README.md -├── PLAN.md # this file (drop after rollout) -├── Makefile # regenerate, build, publish targets -├── src/ -│ └── atlan_cli/ -│ ├── __init__.py # __version__ from importlib.metadata -│ ├── __main__.py # python -m atlan_cli -│ ├── app.py # cyclopts.App + main() entry -│ ├── auth/ -│ │ ├── __init__.py -│ │ ├── config.py # ~/.atlan/config.json read/write -│ │ ├── keyring_store.py # access/refresh token storage in OS keychain -│ │ ├── oauth.py # browser flow + refresh-token reuse -│ │ ├── api_key.py # api-key validation/storage -│ │ └── resolver.py # `_resolve_auth()` — picks mode, refreshes if needed -│ ├── commands/ -│ │ ├── login.py # atlan login / logout / status -│ │ └── tools/ -│ │ └── _generated.py # output of `fastmcp generate-cli` — tool commands -│ ├── transport.py # Client(...) wrapper, exit-code mapping -│ └── output.py # rich formatters, --json mode, exit codes -├── scripts/ -│ └── merge_generated.py # post-process fastmcp's output, re-apply our top block -└── .github/workflows/ - └── mcp-cli-publish.yaml # PyPI publish on tag mcp-cli-v* -``` - -### 3.2 Config / credentials storage - -| File | Contents | Permissions | +| Path | Contents | Permissions | |------|----------|-------------| -| `~/.atlan/config.json` | `{"auth_mode": "oauth"\|"api-key", "tenant": "https://..." (api-key only), "client_id": "mcp-client"}` | 0644 | -| OS keyring `atlan-mcp/access_token` | Short-lived JWT (5–60 min) | keychain | -| OS keyring `atlan-mcp/refresh_token` | Long-lived JWT (~30 days) | keychain | -| OS keyring `atlan-mcp/api_key` | Atlan API key (only when in api-key mode) | keychain | -| `~/.atlan/credentials.json` (fallback for headless) | `{"refresh_token": "...", "access_token": "...", "expires_at": }` | **0600** | - -Resolution order on each command: -1. Read `~/.atlan/config.json` → know which mode. -2. **api-key mode**: pull api-key from keyring → done. -3. **oauth mode**: - - Pull access_token. If `now + 30s < expires_at`, use it. - - Else pull refresh_token, hit `https://mcp.atlan.com/oauth/token` with `grant_type=refresh_token`, get new access+refresh, store both. - - If refresh fails → wipe keyring entries (stale cache cleanup) and exit `2` with "Session expired — run `atlan login`". - -### 3.3 Auth resolver pseudo-code - -```python -@dataclass -class ResolvedAuth: - client_spec: str # full MCP URL - auth: httpx.Auth # BearerAuth or pre-fetched Bearer - mode: Literal["oauth", "api-key"] - -async def resolve_auth(*, force_oauth: bool = False, override_api_key: str | None = None) -> ResolvedAuth: - # 1. Per-call overrides win - if override_api_key: - return ResolvedAuth(MCP_URL_API_KEY, BearerAuth(override_api_key), "api-key") - if force_oauth: - return await _do_full_oauth_login(persist=False) - - # 2. Read persisted config - cfg = load_config() - if cfg is None: - raise NotAuthenticated("Run `atlan login` to set up credentials") - - if cfg.auth_mode == "api-key": - api_key = keyring.get("atlan-mcp", "api_key") - if not api_key: - wipe_credentials() - raise NotAuthenticated("API key missing from keychain — run `atlan login`") - return ResolvedAuth(f"{cfg.tenant}/mcp/api-key", BearerAuth(api_key), "api-key") - - # oauth mode - access = keyring.get("atlan-mcp", "access_token_json") - if access and not _expired(access): - return ResolvedAuth(MCP_URL, BearerAuth(access["access_token"]), "oauth") - - # Refresh - refresh = keyring.get("atlan-mcp", "refresh_token") - if not refresh: - wipe_credentials() - raise NotAuthenticated("No refresh token — run `atlan login`") - try: - new = await _refresh(refresh) # POST mcp.atlan.com/oauth/token - keyring.set("atlan-mcp", "access_token_json", new) - if new.get("refresh_token"): # refresh-token rotation - keyring.set("atlan-mcp", "refresh_token", new["refresh_token"]) - return ResolvedAuth(MCP_URL, BearerAuth(new["access_token"]), "oauth") - except RefreshFailed: - wipe_credentials() # mandatory stale-cache wipe - raise NotAuthenticated("Session expired — run `atlan login`") -``` - -### 3.4 `atlan login` flow - -``` -$ atlan login -? How do you want to authenticate? - ▸ OAuth (browser) - API key -[OAuth selected] -✓ Opening browser… - → https://atlan-demo.atlan.com/auth/realms/default/protocol/openid-connect/auth?… -✓ Token received and stored in OS keychain. -✓ You are logged in as abhinav.mathur@atlan.com - Tenant: atlan-demo.atlan.com - Token expires in: 5m (auto-refresh enabled) -``` - -For api-key: - -``` -$ atlan login --api-key sk-xxx -? Tenant URL: https://atlan-demo.atlan.com -✓ Validated key against /api/auth/whoami → user: abhinav.mathur -✓ Stored in OS keychain. -``` +| `~/.atlan/config.json` | `{"auth_mode": "oauth"\|"api-key", "tenant": "..." (api-key only), "client_id": "mcp-client"}` | 0600 | +| OS keyring `atlan-mcp/refresh_token` | Long-lived JWT (~30d) | OS-level | +| OS keyring `atlan-mcp/access_token` | Short-lived JWT JSON `{"access_token", "expires_at"}` | OS-level | +| OS keyring `atlan-mcp/api_key` | Atlan API key (api-key mode only) | OS-level | +| `~/.atlan/credentials.json` (fallback) | Same secrets, used only when `keyring` raises | 0600 | -`atlan logout` deletes config.json + all keyring entries. +### 3.2 Auth resolution -`atlan status`: ``` -✓ Authenticated via OAuth - User: abhinav.mathur@atlan.com - Tenant: atlan-demo.atlan.com - Access token expires in: 8m 32s (auto-refresh on next call) - Refresh token expires in: 29d 17h + ┌── --api-key override? → BearerAuth(key) → {tenant}/mcp/api-key + │ +resolve_auth() ──┼── --oauth override? → fresh browser flow → BearerAuth(token) + │ (don't persist by default) + │ + └── persisted config? + ├── api-key: read api_key from keyring → BearerAuth → {tenant}/mcp/api-key + └── oauth: + ├── access cached & valid → BearerAuth → mcp.atlan.com/mcp + ├── refresh OK → store new tokens → BearerAuth + └── refresh failed → wipe creds → exit 2 ``` -When unauthenticated, status exits `2` with a single-line "Not authenticated. Run `atlan login`." — agents read stdout and act. +### 3.3 Stale-cache wipe invariant ---- - -## 4. Phasing - -### **P0 — Activate demo-ready (today + tonight)** +`wipe_credentials()` is called from exactly **three places**: +1. Refresh fails (`invalid_grant` or HTTP error) — server says token revoked. +2. `atlan logout`. +3. `atlan login` (always wipes before writing — prevents mode-switch cross-contamination). -| # | Item | Files touched | Effort | -|---|------|---------------|--------| -| 1 | Hardcode proxy URL as default (`https://mcp.atlan.com/mcp`) | `atlan_cli.py` | 10 min | -| 2 | Migrate to `~/.atlan/config.json` for auth mode + `keyring` for tokens | new `auth/` module | 1.5 h | -| 3 | `atlan login` (interactive + `--oauth` + `--api-key`) | `commands/login.py` | 2 h | -| 4 | `atlan logout`, `atlan status` | `commands/login.py` | 30 min | -| 5 | Auto-refresh access token via refresh-token grant; wipe on failure | `auth/oauth.py` | 1 h | -| 6 | Drop `--oauth` requirement from per-tool calls — flag becomes per-call override | `app.py`, `transport.py` | 30 min | -| 7 | Cleanups: `.gitignore` build artifacts, drop tracked `.env`, `.venv`, `uv.lock` from git | `.gitignore`, `git rm --cached` | 15 min | -| 8 | Preserve current `pyproject.toml` (entry point `atlan = atlan_cli.app:main`) | `pyproject.toml` | 10 min | +Any other call to wipe is a bug. Transient network errors must not wipe credentials. -P0 deliverable: +### 3.4 Refresh-token grant -```bash -uv tool install /path/to/agent-toolkit/mcp-cli -atlan login # browser opens, log in once -atlan semantic_search_tool --user-query x # works, no flags, no env vars -atlan status # shows auth state ``` +POST https://mcp.atlan.com/oauth/token +Content-Type: application/x-www-form-urlencoded -### **P1 — Polish + PyPI (next sprint)** - -| # | Item | Files | Effort | -|---|------|-------|--------| -| 9 | Pretty output: rich tables for `list-tools`, syntax-highlighted JSON for tool results | `output.py` | 2 h | -| 10 | `--json` flag + `ATLAN_OUTPUT=json` for clean agent parsing | `output.py`, all commands | 1 h | -| 11 | `--save-last` writes `~/.atlan/last_result.json` (Hrushikesh's idea — opt-in) | `output.py` | 30 min | -| 12 | Cyclopts groups in `--help` (Search / Update / Glossary / DQ / Custom Metadata / Tags) | `app.py` | 1 h | -| 13 | `version.txt` + dynamic version + `__version__` exposed | `pyproject.toml`, `__init__.py` | 30 min | -| 14 | GitHub Actions publish workflow (`.github/workflows/mcp-cli-publish.yaml`) — modeled exactly on `pyatlan-publish.yaml` | new file | 1 h | -| 15 | Reserve `atlan-cli` on PyPI; first release | release process | 30 min | -| 16 | `atlan login --api-key` validates against `/api/meta/whoami` before storing | `auth/api_key.py` | 30 min | - -P1 deliverable: `uv tool install atlan-cli` from PyPI works for any agent author. - -### **P2 — Maintenance + nice-to-haves** - -| # | Item | Files | Effort | -|---|------|-------|--------| -| 17 | `Makefile` target for `regenerate` (fastmcp generate-cli + merge script) | `Makefile`, `scripts/merge_generated.py` | 2 h | -| 18 | `atlan list-tools --category glossary` filtering | `commands/tools/list_tools.py` | 30 min | -| 19 | Structured error messages — distinguish auth, network, schema, server | `transport.py` | 1 h | -| 20 | `atlan login --tenant ` for multi-tenant config (`~/.atlan/profiles/.json`); `atlan --profile ...` | `auth/config.py` | 3 h | -| 21 | Token refresh attempted **inside** the request flow (not just on each call boundary) — handles long-running batch where token expires mid-loop | `transport.py` | 1 h | -| 22 | Headless mode: print device-flow URL + code instead of opening browser when `DISPLAY` not set | `auth/oauth.py` | 1 h | -| 23 | `atlan completion zsh|bash|fish` for shell tab-completion | `commands/completion.py` | 1 h | -| 24 | Telemetry opt-in (`atlan login --telemetry on`) — count tool calls, errors, latency | `output.py` | 2 h | - ---- - -## 5. Detailed implementation notes - -### 5.1 Refresh-token flow (P0 #5) - -```python -import httpx, time - -PROXY_TOKEN_ENDPOINT = "https://mcp.atlan.com/oauth/token" - -async def _refresh(refresh_token: str) -> dict: - """Exchange refresh token for new access+refresh. - - Server (mcp_proxy/app.py:646) decodes the refresh token, derives the - tenant from its `iss` claim, and forwards to that tenant's Keycloak. - Returns: {access_token, refresh_token, expires_in, token_type, ...} - """ - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post( - PROXY_TOKEN_ENDPOINT, - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": "mcp-client", - "client_secret": "placeholder", - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if resp.status_code != 200: - raise RefreshFailed(f"{resp.status_code}: {resp.text[:200]}") - body = resp.json() - body["expires_at"] = int(time.time()) + body.get("expires_in", 300) - 30 # 30s safety - return body +grant_type=refresh_token +&refresh_token= +&client_id=mcp-client +&client_secret=placeholder ``` -Storing the result: -- Store `{"access_token": ..., "expires_at": ts}` JSON-encoded under `atlan-mcp/access_token_json`. -- Store the (possibly rotated) refresh token under `atlan-mcp/refresh_token`. -- On `RefreshFailed`, call `wipe_credentials()` immediately so the next call starts fresh — this is the "always remove stale cache" requirement. - -### 5.2 Stale cache invariant (P0 #5) - -`wipe_credentials()` is called from exactly three places: -1. Refresh fails (server says `invalid_grant` → refresh token revoked). -2. `atlan logout`. -3. `atlan login` (always wipes before writing new state — prevents mode-switch cross-contamination). - -Never call `wipe_credentials()` from any other path; otherwise `atlan login` followed by transient network failures could surprise the user with re-prompt loops. - -### 5.3 `--oauth` and `--api-key` as overrides (P0 #6) - -Make them root-level cyclopts parameters (not subcommand args). Cyclopts allows global flags via `app.meta.command()`: - -```python -app = cyclopts.App(name="atlan", help="Atlan MCP CLI") - -# Global override flags consumed in main() before cyclopts parses subcommand -def main() -> None: - args = sys.argv[1:] - overrides = {} - if "--oauth" in args: - args.remove("--oauth") - overrides["force_oauth"] = True - for i, a in enumerate(args): - if a == "--api-key": - overrides["api_key"] = args[i + 1] - del args[i:i + 2] - break - if a.startswith("--api-key="): - overrides["api_key"] = a.split("=", 1)[1] - del args[i] - break - set_overrides(overrides) # module-level, picked up by resolve_auth - sys.argv[1:] = args - app() +Response: +```json +{ + "access_token": "", + "refresh_token": "", + "expires_in": 300, + "token_type": "Bearer" +} ``` -This keeps the per-call override functional without polluting every tool's signature. +Store new access token with `expires_at = now + expires_in - 30` (30s safety margin). If response includes a new refresh token (rotation), replace the stored one. -### 5.4 Pretty output (P1 #9) - -```python -from rich.table import Table -from rich.syntax import Syntax -from rich.console import Console - -console = Console() - -def render_tool_result(content) -> None: - if _is_json(content): - if OUTPUT_MODE == "json": - print(json.dumps(content)) # raw, agent-friendly - return - console.print(Syntax(json.dumps(content, indent=2), "json", theme="monokai")) - else: - console.print(content) -``` +--- -For `list-tools`: +## 4. P0 — Demo-ready (this PR) + +| # | Item | File | Status | +|---|------|------|--------| +| 1 | Hardcode `https://mcp.atlan.com` as default OAuth proxy URL | `atlan_cli.py` | ⏳ | +| 2 | `~/.atlan/config.json` reader/writer | `atlan_cli.py` | ⏳ | +| 3 | Keyring secret store with file fallback | `atlan_cli.py` | ⏳ | +| 4 | `_resolve_auth()` rewrite with override → config → refresh hierarchy | `atlan_cli.py` | ⏳ | +| 5 | `atlan login` (interactive + `--oauth` + `--api-key --tenant`) | `atlan_cli.py` | ⏳ | +| 6 | `atlan logout` | `atlan_cli.py` | ⏳ | +| 7 | `atlan status` | `atlan_cli.py` | ⏳ | +| 8 | Refresh-token grant + stale-cache wipe | `atlan_cli.py` | ⏳ | +| 9 | Move `--oauth` / `--api-key` / `--tenant` / `--json` to global flags in `main()` | `atlan_cli.py` | ⏳ | +| 10 | Exit codes 0/1/2/3 | `atlan_cli.py` | ⏳ | +| 11 | `--json` raw output mode | `atlan_cli.py` | ⏳ | +| 12 | `.gitignore` build artifacts; drop tracked `.env`/`.venv`/`uv.lock` | `.gitignore` | ⏳ | +| 13 | Update README to login-first UX | `README.md` | ⏳ | -```python -table = Table(title="Atlan MCP Tools", show_lines=True) -table.add_column("Category", style="cyan") -table.add_column("Tool") -table.add_column("Description", overflow="fold") -for tool in tools: - table.add_row(_categorize(tool.name), tool.name, tool.description) -console.print(table) -``` +--- -### 5.5 Cleanups (P0 #7) +## 5. P1 — Packaging & polish (next sprint) -`.gitignore` additions: -``` -mcp-cli/.env -mcp-cli/.venv/ -mcp-cli/build/ -mcp-cli/dist/ -mcp-cli/*.egg-info/ -mcp-cli/uv.lock # debatable — pyatlan tracks it; for a tool, prefer not -*.tar.gz -*.zip -``` +- `version.txt` + dynamic version from `importlib.metadata` +- `.github/workflows/mcp-cli-publish.yaml` modeled on `pyatlan-publish.yaml` +- Reserve PyPI name `atlan-cli` (fallback: `atlan-mcp-cli`) +- `uv tool install atlan-cli` — frictionless install for any agent author +- Cyclopts command groups for categorized `--help` (Search / Update / Glossary / DQ / CM / Tags) +- `rich` tables for `list-tools`, syntax-highlighted JSON for tool results +- `questionary` for prettier interactive `atlan login` prompts -`git rm --cached`: -``` -mcp-cli/.env -mcp-cli/uv.lock -mcp-cli/atlan_mcp_cli.egg-info/ -mcp-cli/build/ -atlan-claude-plugin.tar.gz -atlan-claude-plugin.zip -``` +--- -### 5.6 Schema regeneration (P2 #17) +## 6. P2 — Maintenance & nice-to-haves -`scripts/merge_generated.py`: -1. Reads our `commands/tools/_generated.py` to find the line `# === GENERATED TOOL COMMANDS BELOW ===`. -2. Reads fresh fastmcp output, finds the same marker. -3. Replaces everything below the marker, preserves everything above. +- Separate generated commands from auth/runtime via `_generated.py` import + `Makefile regenerate` target with merge script +- Document the regeneration workflow without breaking auth glue +- `atlan list-tools --category glossary` filtering +- Multi-tenant `~/.atlan/profiles/.json` + `atlan --profile ` +- Mid-stream token refresh for long-running batches (today refresh only happens at request-start boundary) +- Headless device-flow when `DISPLAY` not set +- `atlan completion zsh|bash|fish` -`Makefile`: -```makefile -.PHONY: regenerate build publish test +--- -regenerate: - fastmcp generate-cli https://mcp.atlan.com/mcp --auth oauth --output /tmp/atlan_generated.py --force - python scripts/merge_generated.py /tmp/atlan_generated.py src/atlan_cli/commands/tools/_generated.py - rm /tmp/atlan_generated.py +## 7. Public interfaces -build: - uv build +**Commands** +- `atlan login [--oauth | --api-key [--tenant ]]` +- `atlan logout` +- `atlan status` +- `atlan list-tools`, `atlan list-resources`, `atlan list-prompts`, `atlan read-resource`, `atlan get-prompt` +- `atlan [tool args]` for every MCP tool -publish: build - uv publish +**Global flags** (consumed in `main()` before cyclopts): +- `--oauth` — force fresh OAuth this invocation +- `--api-key ` — one-shot API key (requires `--tenant`) +- `--tenant ` — tenant URL for one-shot api-key mode +- `--json` — emit raw JSON to stdout, all logs to stderr -test: - pytest tests/ +**Config file shape** (`~/.atlan/config.json`): +```json +{ + "auth_mode": "oauth", + "client_id": "mcp-client" +} ``` - -### 5.7 GitHub Actions publish workflow (P1 #14) - -Lifted directly from `atlan-python/.github/workflows/pyatlan-publish.yaml`: - -```yaml -name: Publish atlan-cli to PyPI -on: - release: - types: [published] - workflow_dispatch: - -jobs: - deploy: - if: success() && startsWith(github.ref, 'refs/tags/mcp-cli-v') - runs-on: ubuntu-latest - permissions: { contents: read } - defaults: { run: { working-directory: mcp-cli } } - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: { python-version: '3.10' } - - uses: astral-sh/setup-uv@v7 - - run: uv sync --group dev - - run: uv run python scripts/check_tag.py - - env: { UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} } - run: | - uv build - uv publish +or +```json +{ + "auth_mode": "api-key", + "tenant": "https://demo.atlan.com" +} ``` -`scripts/check_tag.py` mirrors pyatlan's — refuses to publish if `version.txt` doesn't match the git tag. +**Secrets** +- Never written to repo files, `.env.example`, logs, or world-readable local files. +- Keyring service name: `atlan-mcp`, account names: `refresh_token`, `access_token`, `api_key`. -### 5.8 Test plan +--- -| Layer | Tests | -|-------|-------| -| `auth/resolver.py` | unit: api-key happy path, oauth happy path, expired access → refresh, refresh fail → wipe | -| `auth/keyring_store.py` | unit: round-trip, missing entry returns None | -| `auth/oauth.py` | mocked httpx — refresh request shape, browser flow stubbed | -| `commands/login.py` | integration: against a local fake proxy serving `/oauth/token` | -| `transport.py` | integration: real `mcp.atlan.com` with a test api-key (CI secret) | -| `output.py` | snapshot tests for rich rendering, --json mode | +## 8. Test plan (P0) + +| Scenario | Expected | +|----------|----------| +| First `atlan login` (OAuth) | Browser opens, callback succeeds, refresh+access tokens in keyring, config written | +| `atlan semantic_search_tool` after login | Works without flags, uses cached access token | +| Access token expired, refresh succeeds | Tool call works, tokens rotated in keyring | +| Refresh token revoked (manual test) | Exit 2, tokens wiped, config retained, message "run atlan login" | +| `atlan login --api-key sk-x --tenant https://t.com` | Validates against `/api/meta/whoami` (or similar), persists | +| `atlan list-tools` in api-key mode | Works without browser | +| `atlan logout` | Wipes config + all keyring entries | +| Per-call `atlan --oauth …` after logout | Works one-shot, does not persist | +| `atlan --api-key sk-x --tenant https://t.com semantic_search_tool …` after logout | Works one-shot, does not persist | +| `atlan status` unauthenticated | Exit 2, single line: "Not authenticated. Run `atlan login`." | +| `atlan status` authenticated | Exit 0, mode + tenant + expiry | +| `--json` flag | Raw JSON to stdout for both success and error | +| Malformed `~/.atlan/config.json` | Exit 3 with clear message | +| Keyring unavailable | Falls back to `~/.atlan/credentials.json` (0600) | --- -## 6. Risks & mitigations +## 9. Assumptions -| Risk | Mitigation | -|------|------------| -| PyPI name `atlan-cli` taken | Fall back to `atlan-mcp-cli`. Squat both for safety. | -| Refresh token revoked by Keycloak admin | Caught by `RefreshFailed` → wipe → user re-`login`. Already handled. | -| `mcp_proxy` branch not yet on prod `mcp.atlan.com` | Verify on Apr 30 cutover; until then, make `PROXY_BASE_URL` configurable (`ATLAN_PROXY_URL` env) for testing. | -| Keychain unavailable in headless Docker / CI | Fallback file `~/.atlan/credentials.json` chmod 600 — already in design. | -| User runs `atlan` and `atlan-cli` separately and gets confused | Single PyPI package, single entry point. | +- `mcp.atlan.com/oauth/token` continues to support `refresh_token` grant. +- The refresh token JWT continues to encode tenant in the `iss` claim (verified against `oauth_proxy/app.py:219`). +- For api-key validation, we use `{tenant}/api/meta/whoami` — to be confirmed; fall back to attempting an MCP `initialize` if `/api/meta/whoami` is not available. +- The generated tool functions in `atlan_cli.py` are the source of truth for tool argument surfaces in P0. --- -## 7. References +## 10. References -- pyatlan packaging: `/Users/abhinav.mathur/atlan-repos/atlan-python/{pyproject.toml,check_tag.py,.github/workflows/pyatlan-publish.yaml}` -- MCP proxy refresh-token impl: `agent-toolkit-internal:mcp_proxy:oauth_proxy/app.py` (lines 219, 646–684) -- OAuth client lib: `mcp.client.auth.OAuthClientProvider` (mcp 1.27) -- Cyclopts docs: https://cyclopts.readthedocs.io/ -- Meeting transcript: Ankit / Hrushikesh / Abhinav — paste in `mcp-cli/.notes/2026-04-28-meeting.md` if we want it tracked +- Proxy implementation: `agent-toolkit-internal:mcp_proxy:oauth_proxy/app.py` (token endpoint at line 608, refresh handling at 646, JWT issuer extraction at 219) +- Live proxy (verified Apr 28): `https://mcp.atlan.com/.well-known/oauth-authorization-server`, `/oauth/token`, `/oauth/register` +- Pyatlan packaging template: `atlan-python:pyproject.toml`, `.github/workflows/pyatlan-publish.yaml` +- Current CLI: `agent-toolkit:feat/mcp-cli:mcp-cli/atlan_cli.py` diff --git a/mcp-cli/README.md b/mcp-cli/README.md index 3d76ec2..2b7b9e8 100644 --- a/mcp-cli/README.md +++ b/mcp-cli/README.md @@ -10,58 +10,56 @@ Install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) if you d uv tool install /path/to/agent-toolkit/mcp-cli ``` -This gives you the `atlan` command globally. Add `~/.local/bin` to your PATH if prompted: +Add `~/.local/bin` to your PATH if prompted: ```bash export PATH="$HOME/.local/bin:$PATH" ``` -## Configuration - -Set your tenant URL and (optionally) an API key — either via environment variables or a `.env` file in the directory where you run `atlan`: +## Quick Start ```bash -# .env -ATLAN_BASE_URL=https://your-tenant.atlan.com -ATLAN_API_KEY=your-api-key # omit to use OAuth browser login -``` +# One-time login — opens browser for OAuth +atlan login -## Auth Modes +# Or log in with an API key +atlan login --api-key sk-xxx --tenant https://your-tenant.atlan.com -| Mode | How to activate | Endpoint used | -|------|----------------|---------------| -| **API key** | `ATLAN_BASE_URL` + `ATLAN_API_KEY` | `{base_url}/mcp/api-key` | -| **OAuth (PKCE)** | `ATLAN_BASE_URL` only, no `ATLAN_API_KEY` | `{base_url}/mcp` | -| **Force OAuth** | `--oauth` flag or `ATLAN_AUTH=oauth` | `{base_url}/mcp` | +# Check your auth status +atlan status -OAuth tokens are cached in the OS keychain after the first browser login — subsequent runs skip the browser entirely. - -The `--oauth` flag forces browser login even when `ATLAN_API_KEY` is set in `.env`. +# Run tools — no flags needed after login +atlan semantic_search_tool --user-query "PII tables in Snowflake" +atlan list-tools +atlan get_asset_tool --guid "abc-123" +``` -## Usage +## Auth Commands -```bash -# Show all available commands -atlan --help +| Command | Description | +|---------|-------------| +| `atlan login` | OAuth browser login (default) | +| `atlan login --api-key KEY --tenant URL` | Log in with an API key | +| `atlan logout` | Remove stored credentials | +| `atlan status` | Show auth mode, tenant, and token expiry | -# List tools the MCP server exposes -atlan --oauth list-tools +## Global Flags (per-call overrides) -# Search assets (OAuth) -atlan --oauth semantic_search_tool --user-query "PII tables in Snowflake" +These override stored credentials for a single invocation and are not persisted: -# Search assets (API key — reads .env automatically) -atlan semantic_search_tool --user-query "PII tables in Snowflake" +| Flag | Description | +|------|-------------| +| `--oauth` | Force fresh OAuth browser login this call | +| `--api-key KEY --tenant URL` | One-shot API key (not saved) | +| `--json` | Raw JSON to stdout; all logs go to stderr | -# Traverse lineage -atlan --oauth traverse_lineage_tool --guid "abc-123" --direction DOWNSTREAM - -# Get a specific asset -atlan --oauth get_asset_tool --guid "abc-123" +```bash +# One-shot overrides (no stored credentials needed) +atlan --oauth semantic_search_tool --user-query "PII tables" +atlan --api-key sk-xxx --tenant https://demo.atlan.com list-tools +atlan --json get_asset_tool --guid abc-123 ``` -> **Note:** `--oauth` must come before the tool name (it's a global flag, not a tool argument). - ## Available Tools - **Search & discovery** — `semantic_search_tool`, `search_assets_tool`, `traverse_lineage_tool` @@ -74,6 +72,17 @@ atlan --oauth get_asset_tool --guid "abc-123" - **Tags** — `add_atlan_tags_tool`, `remove_atlan_tag_tool` Run `atlan --help` to see all commands with their parameters. +Run `atlan list-tools` to see what the live MCP server exposes (requires auth). + +## How Credentials Are Stored + +| Storage | Contents | +|---------|----------| +| `~/.atlan/config.json` | Auth mode and tenant URL (no secrets) | +| OS keychain (`atlan-mcp`) | Access token, refresh token, or API key | +| `~/.atlan/credentials.json` | File fallback when OS keychain is unavailable | + +OAuth access tokens auto-refresh via the `mcp.atlan.com` proxy — you rarely need to re-run `atlan login`. ## Updating @@ -91,4 +100,4 @@ If tool schemas change (new tools added to the server), regenerate with: fastmcp generate-cli https://your-tenant.atlan.com/mcp --auth oauth --output atlan_cli.py --force ``` -Then re-apply the auth/packaging block at the top (everything above `app = cyclopts.App(...)`) — `fastmcp` does not write auth into generated scripts by design. +Re-apply the auth/packaging block at the top after regeneration — `fastmcp` does not write auth into generated scripts. diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index c272df3..b23f0ff 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -4,10 +4,13 @@ Generated by: fastmcp generate-cli https://your-tenant.atlan.com/mcp """ +__version__ = "0.1.0" + import hashlib import json import os import sys +import time from pathlib import Path from typing import Annotated, Any, Mapping @@ -23,6 +26,12 @@ from fastmcp import Client from fastmcp.client.auth import BearerAuth, OAuth +_ATLAN_DIR = Path.home() / ".atlan" +_CONFIG_FILE = _ATLAN_DIR / "config.json" +_CREDS_FILE = _ATLAN_DIR / "credentials.json" +_OAUTH_PROXY = "https://mcp.atlan.com" +_JSON_MODE = False + class _KeyringStore: """AsyncKeyValue store backed by the OS keychain (macOS Keychain, etc.). @@ -103,10 +112,94 @@ async def get_many(self, keys: list[str], *, collection: str | None = None) -> l return [data.get(k) for k in keys] -# Auth resolution (lazy — evaluated on first tool/command call): -# --oauth flag or ATLAN_AUTH=oauth → OAuth regardless of ATLAN_API_KEY -# ATLAN_BASE_URL + ATLAN_API_KEY → Bearer auth against {base_url}/mcp/api-key -# ATLAN_BASE_URL (no API key) → OAuth against {base_url}/mcp +class _CapturingStore(_JsonFileStore): + """Intercepts FastMCP OAuth token storage to also save to our canonical keyring format.""" + + async def put(self, key: str, value: Mapping[str, Any], *, collection: str | None = None, ttl: Any = None) -> None: + await super().put(key, value, collection=collection, ttl=ttl) + v = dict(value) + if "access_token" in v: + expires_in = v.get("expires_in", 300) + _keyring_set("access_token", json.dumps({ + "access_token": v["access_token"], + "expires_at": time.time() + expires_in - 30, + })) + if "refresh_token" in v: + _keyring_set("refresh_token", str(v["refresh_token"])) + + +def _keyring_get(account: str) -> str | None: + try: + import keyring as kr + val = kr.get_password("atlan-mcp", account) + if val is not None: + return val + except Exception: + pass + if _CREDS_FILE.exists(): + try: + return json.loads(_CREDS_FILE.read_text()).get(account) + except Exception: + pass + return None + + +def _keyring_set(account: str, value: str) -> None: + try: + import keyring as kr + kr.set_password("atlan-mcp", account, value) + return + except Exception: + pass + _ATLAN_DIR.mkdir(parents=True, exist_ok=True) + data: dict = {} + if _CREDS_FILE.exists(): + try: + data = json.loads(_CREDS_FILE.read_text()) + except Exception: + pass + data[account] = value + _CREDS_FILE.write_text(json.dumps(data)) + _CREDS_FILE.chmod(0o600) + + +def _keyring_delete(account: str) -> None: + try: + import keyring as kr + kr.delete_password("atlan-mcp", account) + except Exception: + pass + if _CREDS_FILE.exists(): + try: + data = json.loads(_CREDS_FILE.read_text()) + data.pop(account, None) + _CREDS_FILE.write_text(json.dumps(data)) + except Exception: + pass + + +def _read_config() -> dict | None: + if not _CONFIG_FILE.exists(): + return None + try: + return json.loads(_CONFIG_FILE.read_text()) + except Exception: + _stderr.print("[bold red]Error:[/bold red] Malformed ~/.atlan/config.json. Run [cyan]atlan login[/cyan].") + sys.exit(3) + + +def _write_config(cfg: dict) -> None: + _ATLAN_DIR.mkdir(parents=True, exist_ok=True) + _CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) + _CONFIG_FILE.chmod(0o600) + + +def _wipe_credentials() -> None: + """Wipe all stored credentials. Called ONLY from: refresh failure, logout, login.""" + for account in ("refresh_token", "access_token", "api_key"): + _keyring_delete(account) + + _resolved: tuple[str, object] | None = None @@ -114,20 +207,106 @@ def _resolve_auth() -> tuple[str, object]: global _resolved if _resolved is not None: return _resolved - base_url = os.environ.get("ATLAN_BASE_URL", "").rstrip("/") - if not base_url: - raise SystemExit("Error: ATLAN_BASE_URL must be set (e.g. https://your-tenant.atlan.com)") - api_key = os.environ.get("ATLAN_API_KEY") - force_oauth = os.environ.get("ATLAN_AUTH", "").lower() == "oauth" - token_store = _KeyringStore() - if api_key and not force_oauth: - client_spec = f"{base_url}/mcp/api-key" - auth = BearerAuth(api_key) - else: - client_spec = f"{base_url}/mcp" - auth = OAuth(mcp_url=client_spec, token_storage=token_store) - _resolved = (client_spec, auth) - return _resolved + + override_api_key = os.environ.get("_ATLAN_OVERRIDE_API_KEY") + override_tenant = os.environ.get("_ATLAN_OVERRIDE_TENANT", "").rstrip("/") + override_oauth = os.environ.get("_ATLAN_OVERRIDE_OAUTH") == "1" + + if override_api_key and override_tenant: + _resolved = (f"{override_tenant}/mcp/api-key", BearerAuth(override_api_key)) + return _resolved + + if override_oauth: + url = f"{_OAUTH_PROXY}/mcp" + store = _CapturingStore(_ATLAN_DIR / ".fastmcp-cache") + _resolved = (url, OAuth(mcp_url=url, token_storage=store)) + return _resolved + + cfg = _read_config() + + if cfg is None: + # Legacy env-var fallback for users who haven't run atlan login yet + base_url = os.environ.get("ATLAN_BASE_URL", "").rstrip("/") + if not base_url: + _err("Not authenticated. Run [cyan]atlan login[/cyan].") + sys.exit(2) + api_key = os.environ.get("ATLAN_API_KEY") + force_oauth = os.environ.get("ATLAN_AUTH", "").lower() == "oauth" + if api_key and not force_oauth: + _resolved = (f"{base_url}/mcp/api-key", BearerAuth(api_key)) + else: + store = _CapturingStore(_ATLAN_DIR / ".fastmcp-cache") + url = f"{base_url}/mcp" + _resolved = (url, OAuth(mcp_url=url, token_storage=store)) + return _resolved + + auth_mode = cfg.get("auth_mode") + + if auth_mode == "api-key": + tenant = cfg.get("tenant", "").rstrip("/") + if not tenant: + _err("Config error: api-key mode requires tenant URL. Run [cyan]atlan login[/cyan].") + sys.exit(3) + api_key = _keyring_get("api_key") + if not api_key: + _err("Not authenticated. Run [cyan]atlan login[/cyan].") + sys.exit(2) + _resolved = (f"{tenant}/mcp/api-key", BearerAuth(api_key)) + return _resolved + + if auth_mode == "oauth": + at_raw = _keyring_get("access_token") + if at_raw: + try: + at_data = json.loads(at_raw) + if time.time() < at_data.get("expires_at", 0): + _resolved = (f"{_OAUTH_PROXY}/mcp", BearerAuth(at_data["access_token"])) + return _resolved + except Exception: + pass + + refresh_token = _keyring_get("refresh_token") + if not refresh_token: + _err("Not authenticated. Run [cyan]atlan login[/cyan].") + sys.exit(2) + + import httpx + try: + resp = httpx.post( + f"{_OAUTH_PROXY}/oauth/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": cfg.get("client_id", "mcp-client"), + "client_secret": "placeholder", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + except Exception as exc: + _err(f"Network error during token refresh: {exc}") + sys.exit(2) + + if resp.status_code != 200: + _wipe_credentials() + _err("Session expired. Run [cyan]atlan login[/cyan].") + sys.exit(2) + + data = resp.json() + access_token = data["access_token"] + expires_in = data.get("expires_in", 300) + _keyring_set("access_token", json.dumps({ + "access_token": access_token, + "expires_at": time.time() + expires_in - 30, + })) + if "refresh_token" in data: + _keyring_set("refresh_token", data["refresh_token"]) + + _resolved = (f"{_OAUTH_PROXY}/mcp", BearerAuth(access_token)) + return _resolved + + _err(f"Unknown auth_mode {auth_mode!r}. Run [cyan]atlan login[/cyan].") + sys.exit(3) def _client() -> "Client": url, auth = _resolve_auth() @@ -137,6 +316,11 @@ def _client() -> "Client": app = cyclopts.App(name="atlan", help="CLI for Atlan MCP server") console = Console() +_stderr = Console(stderr=True) + + +def _err(msg: str) -> None: + _stderr.print(msg) # --------------------------------------------------------------------------- @@ -144,28 +328,42 @@ def _client() -> "Client": # --------------------------------------------------------------------------- -def _print_tool_result(result): +def _print_tool_result(result) -> None: if result.is_error: for block in result.content: if isinstance(block, mcp.types.TextContent): - console.print(f"[bold red]Error:[/bold red] {block.text}") + if _JSON_MODE: + print(json.dumps({"error": block.text})) + else: + _stderr.print(f"[bold red]Error:[/bold red] {block.text}") else: - console.print(f"[bold red]Error:[/bold red] {block}") + if _JSON_MODE: + print(json.dumps({"error": str(block)})) + else: + _stderr.print(f"[bold red]Error:[/bold red] {block}") sys.exit(1) if result.structured_content is not None: - console.print_json(json.dumps(result.structured_content)) + if _JSON_MODE: + print(json.dumps(result.structured_content)) + else: + console.print_json(json.dumps(result.structured_content)) return for block in result.content: if isinstance(block, mcp.types.TextContent): - console.print(block.text) + if _JSON_MODE: + print(block.text) + else: + console.print(block.text) elif isinstance(block, mcp.types.ImageContent): size = len(block.data) * 3 // 4 - console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") + if not _JSON_MODE: + console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") elif isinstance(block, mcp.types.AudioContent): size = len(block.data) * 3 // 4 - console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") + if not _JSON_MODE: + console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") async def _call_tool(tool_name: str, arguments: dict) -> None: @@ -190,26 +388,26 @@ async def _call_tool(tool_name: str, arguments: dict) -> None: @app.command async def list_tools() -> None: """List available tools.""" + from rich.table import Table async with _client() as client: tools = await client.list_tools() - if not tools: - console.print("[dim]No tools found.[/dim]") - return - for tool in tools: - sig_parts = [] - props = tool.inputSchema.get("properties", {}) - required = set(tool.inputSchema.get("required", [])) - for pname, pschema in props.items(): - ptype = pschema.get("type", "string") - if pname in required: - sig_parts.append(f"{pname}: {ptype}") - else: - sig_parts.append(f"{pname}: {ptype} = ...") - sig = f"{tool.name}({', '.join(sig_parts)})" - console.print(f" [cyan]{sig}[/cyan]") - if tool.description: - console.print(f" {tool.description}") - console.print() + if not tools: + console.print("[dim]No tools found.[/dim]") + return + table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 2)) + table.add_column("Tool", style="cyan", no_wrap=True) + table.add_column("Description") + table.add_column("Parameters", style="dim") + for tool in tools: + props = tool.inputSchema.get("properties", {}) + required = set(tool.inputSchema.get("required", [])) + params = ", ".join( + f"[bold]{n}[/bold]" if n in required else n + for n in props + ) + table.add_row(tool.name, tool.description or "", params) + console.print(table) + console.print(f"\n[dim]Bold = required · {len(tools)} tools total[/dim]") @app.command @@ -289,6 +487,183 @@ async def get_prompt( console.print() +# --------------------------------------------------------------------------- +# Auth commands +# --------------------------------------------------------------------------- + + +@app.command +async def login( + *, + oauth: Annotated[bool, cyclopts.Parameter("--oauth", help="Force OAuth browser login")] = False, + api_key: Annotated[str | None, cyclopts.Parameter("--api-key", help="Atlan API key")] = None, + tenant: Annotated[str | None, cyclopts.Parameter("--tenant", help="Tenant URL (required with --api-key)")] = None, +) -> None: + """Authenticate with Atlan. Run once; tool calls reuse stored credentials. + + With no flags: interactive chooser (OAuth vs API key). + --oauth: browser OAuth flow directly. + --api-key KEY --tenant URL: API key mode. + """ + # Always wipe first to prevent mode-switch cross-contamination. + _wipe_credentials() + if _CONFIG_FILE.exists(): + _CONFIG_FILE.unlink() + + if api_key and not tenant: + _err("[bold red]Error:[/bold red] --tenant is required with --api-key") + sys.exit(3) + + if api_key and tenant: + tenant = tenant.rstrip("/") + import httpx + try: + resp = httpx.get( + f"{tenant}/api/meta/types/typedefs", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + timeout=10, + follow_redirects=True, + ) + except Exception as exc: + _err(f"Network error validating API key: {exc}") + sys.exit(3) + if resp.status_code in (401, 403): + _err(f"API key validation failed (HTTP {resp.status_code}). Check key and tenant URL.") + sys.exit(3) + _keyring_set("api_key", api_key) + _write_config({"auth_mode": "api-key", "tenant": tenant}) + from rich.panel import Panel + console.print(Panel( + f" Mode [cyan]api-key[/cyan]\n Tenant {tenant}\n\nRun [bold]atlan status[/bold] to verify connectivity.", + title="[green]● Login successful[/green]", + expand=False, + )) + return + + use_oauth = oauth or bool(os.environ.get("_ATLAN_OVERRIDE_OAUTH")) + + if not use_oauth: + # Interactive chooser — falls back to OAuth if questionary not installed. + try: + import questionary # type: ignore + choice = questionary.select( + "How would you like to log in?", + choices=["OAuth — browser login via mcp.atlan.com", "API key — paste your Atlan API key"], + ).ask() + if choice is None: + sys.exit(0) + if choice == "API key — paste your Atlan API key": + api_key_in = questionary.password("API key:").ask() + tenant_in = questionary.text("Tenant URL (e.g. https://demo.atlan.com):").ask() + if not api_key_in or not tenant_in: + _err("API key and tenant are required.") + sys.exit(3) + await login(oauth=False, api_key=api_key_in, tenant=tenant_in) + return + except ImportError: + pass + use_oauth = True + + # OAuth browser flow. + # FastMCP wraps token_storage in PydanticAdapter, so we can't intercept put() reliably. + # Instead: complete the flow, then read tokens back from the OAuth adapter directly. + url = f"{_OAUTH_PROXY}/mcp" + store = _JsonFileStore(_ATLAN_DIR / ".fastmcp-cache") + auth = OAuth(mcp_url=url, token_storage=store) + console.print(f"Opening browser for OAuth login at [cyan]{_OAUTH_PROXY}[/cyan] …") + try: + async with Client(url, auth=auth) as client: + await client.list_tools() + except Exception as exc: + _err(f"OAuth login failed: {exc}") + sys.exit(2) + + # Extract tokens from the completed OAuth flow and persist to our canonical keyring. + tokens = await auth.token_storage_adapter.get_tokens() + if tokens and tokens.access_token: + expires_at = time.time() + (tokens.expires_in or 300) - 30 + _keyring_set("access_token", json.dumps({ + "access_token": tokens.access_token, + "expires_at": expires_at, + })) + if tokens.refresh_token: + _keyring_set("refresh_token", tokens.refresh_token) + + _write_config({"auth_mode": "oauth", "client_id": "mcp-client"}) + from rich.panel import Panel + console.print(Panel( + f" Mode [cyan]oauth[/cyan]\n Proxy {_OAUTH_PROXY}/mcp\n\nRun [bold]atlan status[/bold] to check token validity.", + title="[green]● Login successful[/green]", + expand=False, + )) + + +@app.command +async def logout() -> None: + """Remove stored credentials. Run atlan login to re-authenticate.""" + _wipe_credentials() + if _CONFIG_FILE.exists(): + _CONFIG_FILE.unlink() + console.print("[green]Logged out.[/green] Run [cyan]atlan login[/cyan] to authenticate again.") + + +@app.command +async def status() -> None: + """Show current authentication state and token validity.""" + cfg = _read_config() + if cfg is None: + _err("Not authenticated. Run [cyan]atlan login[/cyan].") + sys.exit(2) + + auth_mode = cfg.get("auth_mode", "unknown") + + from rich.panel import Panel + + if auth_mode == "api-key": + tenant = cfg.get("tenant", "?") + has_key = bool(_keyring_get("api_key")) + if has_key: + lines = [ + f" Mode [cyan]api-key[/cyan]", + f" Tenant {tenant}", + ] + console.print(Panel("\n".join(lines), title="[green]● Authenticated[/green]", expand=False)) + else: + _err("Config present but API key missing. Run [cyan]atlan login[/cyan].") + sys.exit(2) + + elif auth_mode == "oauth": + at_raw = _keyring_get("access_token") + rt = _keyring_get("refresh_token") + if at_raw: + try: + at_data = json.loads(at_raw) + remaining = at_data.get("expires_at", 0) - time.time() + if remaining > 0: + lines = [ + f" Mode [cyan]oauth[/cyan]", + f" Proxy {_OAUTH_PROXY}/mcp", + f" Token expires in [cyan]{int(remaining)}s[/cyan]", + f" Refresh {'[green]present[/green]' if rt else '[red]missing[/red]'}", + ] + console.print(Panel("\n".join(lines), title="[green]● Authenticated[/green]", expand=False)) + return + except Exception: + pass + if rt: + console.print(Panel( + f" Mode [cyan]oauth[/cyan]\n Token [yellow]expired[/yellow] — will auto-refresh on next call\n Refresh [green]present[/green]", + title="[yellow]● Token Expired[/yellow]", + expand=False, + )) + else: + _err("Not authenticated. Run [cyan]atlan login[/cyan].") + sys.exit(2) + else: + _err(f"Unknown auth_mode {auth_mode!r}. Run [cyan]atlan login[/cyan].") + sys.exit(3) + + # --------------------------------------------------------------------------- # Tool commands (generated from server schema) # --------------------------------------------------------------------------- @@ -821,10 +1196,40 @@ async def get_asset_icons( def main() -> None: - if "--oauth" in sys.argv: - sys.argv.remove("--oauth") - os.environ["ATLAN_AUTH"] = "oauth" - app() + global _JSON_MODE + + args = sys.argv[1:] + subcommand = next((a for a in args if not a.startswith("-")), None) + is_login = subcommand == "login" + + new_args: list[str] = [] + i = 0 + while i < len(args): + a = args[i] + if a == "--oauth": + os.environ["_ATLAN_OVERRIDE_OAUTH"] = "1" + # Don't add to new_args — consume the flag globally + elif a == "--json": + _JSON_MODE = True + # Don't add to new_args — consume globally + elif a == "--api-key" and i + 1 < len(args) and not is_login: + os.environ["_ATLAN_OVERRIDE_API_KEY"] = args[i + 1] + i += 1 # skip value too + elif a == "--tenant" and i + 1 < len(args) and not is_login: + os.environ["_ATLAN_OVERRIDE_TENANT"] = args[i + 1] + i += 1 # skip value too + else: + new_args.append(a) + i += 1 + + sys.argv = [sys.argv[0]] + new_args + try: + app() + except SystemExit: + raise + except Exception as exc: + _err(f"[bold red]Error:[/bold red] {exc}") + sys.exit(1) if __name__ == "__main__": diff --git a/mcp-cli/pyproject.toml b/mcp-cli/pyproject.toml index dca3be7..1c6ab46 100644 --- a/mcp-cli/pyproject.toml +++ b/mcp-cli/pyproject.toml @@ -1,21 +1,48 @@ [build-system] -requires = ["setuptools>=61"] -build-backend = "setuptools.build_meta" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] -name = "atlan-mcp-cli" -version = "0.1.0" +name = "atlan-cli" +dynamic = ["version"] +description = "CLI for calling Atlan MCP tools directly from your terminal" +readme = "README.md" requires-python = ">=3.10" +license = { text = "Apache-2.0" } +keywords = ["atlan", "mcp", "cli", "data-catalog"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] dependencies = [ - "cyclopts", + "cyclopts>=3.0", "fastmcp>=2.14,<3", - "python-dotenv", - "keyring", - "rich", + "python-dotenv>=1.0", + "keyring>=25.0", + "rich>=13.0", + "httpx>=0.27", ] +[project.urls] +Homepage = "https://github.com/atlanhq/agent-toolkit" +Repository = "https://github.com/atlanhq/agent-toolkit" +"Bug Tracker" = "https://github.com/atlanhq/agent-toolkit/issues" + [project.scripts] atlan = "atlan_cli:main" -[tool.setuptools] -py-modules = ["atlan_cli"] +[tool.hatch.version] +path = "atlan_cli.py" + +[tool.hatch.build.targets.wheel] +include = ["atlan_cli.py"] + +[tool.hatch.build.targets.sdist] +include = ["atlan_cli.py", "README.md", "pyproject.toml"] From 54ae8960ca74191f326a356029e8591ff0259ef4 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 13:54:01 +0530 Subject: [PATCH 16/26] fix(mcp-cli): clear FastMCP token cache on logout so login --oauth always opens browser Without this, .fastmcp-cache/ survived logout and FastMCP reused cached tokens silently, making tenant switching impossible without manual cache deletion. Co-Authored-By: Claude Sonnet 4.6 --- mcp-cli/atlan_cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index b23f0ff..b25e805 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -198,6 +198,11 @@ def _wipe_credentials() -> None: """Wipe all stored credentials. Called ONLY from: refresh failure, logout, login.""" for account in ("refresh_token", "access_token", "api_key"): _keyring_delete(account) + # Clear FastMCP's internal token cache so next login --oauth always opens browser. + import shutil + cache = _ATLAN_DIR / ".fastmcp-cache" + if cache.exists(): + shutil.rmtree(cache) _resolved: tuple[str, object] | None = None From 0bfa71f037a5086da0e7838f5bb7cf234a5f0647 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 16:01:03 +0530 Subject: [PATCH 17/26] refactor(mcp-cli): simplify code and make JSON arg parsing schema-aware - Remove dead _KeyringStore and _CapturingStore classes (never used in practice; flat keyring helpers and direct token extraction cover all paths) - Add _maybe_json() helper: safe json.loads with fallback for non-JSON strings, replacing bare json.loads() which crashed on free-form text - Make argument parsing schema-aware: pure string|null params (title, message, announcement_type, guid, qualified_name, asset_type, name_filter, group_id, default_schema, connection_qualified_name, sort_by, glossary_qualified_name, include_attributes in traverse/get_asset) now pass through without JSON decoding; object/array/integer params continue to use _maybe_json() - Fix _wipe_credentials() to reset cached _resolved auth so logout is effective in long-lived processes - Move Panel import to top-level; remove three inline from-imports - Remove # Parse JSON parameters comments throughout generated section - Improve README: namespace-type enum values, exit codes table, cross-platform keychain details, write-tool propose/execute workflow, tool category table Co-Authored-By: Claude Sonnet 4.6 --- mcp-cli/README.md | 86 ++++++++++---- mcp-cli/atlan_cli.py | 268 +++++++++++++------------------------------ 2 files changed, 141 insertions(+), 213 deletions(-) diff --git a/mcp-cli/README.md b/mcp-cli/README.md index 2b7b9e8..6ef95ca 100644 --- a/mcp-cli/README.md +++ b/mcp-cli/README.md @@ -1,6 +1,6 @@ # Atlan MCP CLI -A standalone CLI for calling Atlan MCP tools directly from your terminal — no IDE, no agent required. +A standalone CLI for calling Atlan MCP tools directly from your terminal — no IDE, no agent required. Works on macOS, Windows, and Linux. ## Installation @@ -19,7 +19,7 @@ export PATH="$HOME/.local/bin:$PATH" ## Quick Start ```bash -# One-time login — opens browser for OAuth +# One-time login — opens browser (OAuth via mcp.atlan.com) atlan login # Or log in with an API key @@ -38,12 +38,15 @@ atlan get_asset_tool --guid "abc-123" | Command | Description | |---------|-------------| -| `atlan login` | OAuth browser login (default) | -| `atlan login --api-key KEY --tenant URL` | Log in with an API key | -| `atlan logout` | Remove stored credentials | +| `atlan login` | Interactive: choose OAuth browser login or API key | +| `atlan login --oauth` | Force OAuth browser flow directly | +| `atlan login --api-key KEY --tenant URL` | Log in with an Atlan API key | +| `atlan logout` | Remove all stored credentials | | `atlan status` | Show auth mode, tenant, and token expiry | -## Global Flags (per-call overrides) +OAuth access tokens auto-refresh via the `mcp.atlan.com` proxy — you rarely need to re-run `atlan login`. To switch tenants, run `atlan logout` then `atlan login` again. + +## Global Flags These override stored credentials for a single invocation and are not persisted: @@ -54,7 +57,7 @@ These override stored credentials for a single invocation and are not persisted: | `--json` | Raw JSON to stdout; all logs go to stderr | ```bash -# One-shot overrides (no stored credentials needed) +# One-shot overrides atlan --oauth semantic_search_tool --user-query "PII tables" atlan --api-key sk-xxx --tenant https://demo.atlan.com list-tools atlan --json get_asset_tool --guid abc-123 @@ -62,27 +65,64 @@ atlan --json get_asset_tool --guid abc-123 ## Available Tools -- **Search & discovery** — `semantic_search_tool`, `search_assets_tool`, `traverse_lineage_tool` -- **Asset detail** — `get_asset_tool`, `resolve_metadata_tool` -- **Asset updates** — `update_assets_tool`, `manage_announcements_tool`, `manage_asset_lifecycle_tool` -- **Glossary** — `create_glossaries`, `create_glossary_terms`, `create_glossary_categories` -- **Data governance** — `create_domains`, `create_data_products` -- **Data quality** — `create_dq_rules_tool`, `update_dq_rules_tool`, `schedule_dq_rules_tool`, `delete_dq_rules_tool` -- **Custom metadata** — `update_custom_metadata_tool`, `remove_custom_metadata_tool`, `create_custom_metadata_set_tool` -- **Tags** — `add_atlan_tags_tool`, `remove_atlan_tag_tool` +Run `atlan list-tools` to see the full list (requires auth). Common tools by category: + +| Category | Tools | +|----------|-------| +| **Search & discovery** | `semantic_search_tool`, `search_assets_tool`, `search_atlan_docs_tool` | +| **Lineage** | `traverse_lineage_tool` | +| **Asset detail** | `get_asset_tool`, `resolve_metadata_tool`, `get_groups_tool` | +| **Asset updates** | `update_assets_tool`, `manage_announcements_tool`, `manage_asset_lifecycle_tool` | +| **Glossary** | `create_glossaries`, `create_glossary_terms`, `create_glossary_categories` | +| **Data mesh** | `create_domains`, `create_data_products` | +| **Data quality** | `create_dq_rules_tool`, `update_dq_rules_tool`, `schedule_dq_rules_tool`, `delete_dq_rules_tool` | +| **Custom metadata** | `create_custom_metadata_set_tool`, `update_custom_metadata_tool`, `remove_custom_metadata_tool`, `add_attributes_to_cm_set_tool`, `remove_attributes_from_cm_set_tool`, `delete_custom_metadata_set_tool` | +| **Tags** | `add_atlan_tags_tool`, `remove_atlan_tag_tool` | +| **Query** | `query_assets_tool`, `query_deep_sql_tool` | + +Write tools (`create_*`, `update_*`, `delete_*`) default to `--mode propose` which shows a preview without making changes. Pass `--mode execute` only after reviewing the proposal. + +```bash +# Preview a change (safe — no writes) +atlan update_assets_tool --updates '{"guid":"abc","name":"t","qualified_name":"qn","type_name":"Table","user_description":"new desc"}' --mode propose + +# See tool parameters +atlan semantic_search_tool --help +``` + +## resolve_metadata_tool — Valid Namespace Types + +The `--namespace-type` argument accepts these values: -Run `atlan --help` to see all commands with their parameters. -Run `atlan list-tools` to see what the live MCP server exposes (requires auth). +- `users` — search Atlan users +- `classifications` — Atlan tag/classification names +- `business_metadata` — custom metadata set names +- `glossary` — glossary names and terms +- `data_domain_and_product` — data domains and products + +```bash +atlan resolve_metadata_tool --namespace-type users --query "john" +atlan resolve_metadata_tool --namespace-type glossary --query "revenue" +``` ## How Credentials Are Stored -| Storage | Contents | -|---------|----------| -| `~/.atlan/config.json` | Auth mode and tenant URL (no secrets) | -| OS keychain (`atlan-mcp`) | Access token, refresh token, or API key | -| `~/.atlan/credentials.json` | File fallback when OS keychain is unavailable | +| Storage | Contents | Permissions | +|---------|----------|-------------| +| `~/.atlan/config.json` | Auth mode and tenant URL (no secrets) | 0600 | +| OS keychain (`atlan-mcp`) | Access token, refresh token, or API key | OS-encrypted | +| `~/.atlan/credentials.json` | Fallback when OS keychain is unavailable | 0600 | + +The OS keychain is used automatically on macOS (Keychain), Windows (Credential Manager), and Linux (Secret Service / GNOME Keyring). The file fallback activates on headless servers and CI environments. + +## Exit Codes -OAuth access tokens auto-refresh via the `mcp.atlan.com` proxy — you rarely need to re-run `atlan login`. +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Tool returned an error | +| `2` | Not authenticated — run `atlan login` | +| `3` | Config or invocation error | ## Updating diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index b25e805..2bc60d9 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -14,17 +14,16 @@ from pathlib import Path from typing import Annotated, Any, Mapping -from dotenv import load_dotenv - -load_dotenv(Path.cwd() / ".env", override=False) -load_dotenv(Path(__file__).parent / ".env", override=False) - import cyclopts import mcp.types -from rich.console import Console - +from dotenv import load_dotenv from fastmcp import Client from fastmcp.client.auth import BearerAuth, OAuth +from rich.console import Console +from rich.panel import Panel + +load_dotenv(Path.cwd() / ".env", override=False) +load_dotenv(Path(__file__).parent / ".env", override=False) _ATLAN_DIR = Path.home() / ".atlan" _CONFIG_FILE = _ATLAN_DIR / "config.json" @@ -33,38 +32,6 @@ _JSON_MODE = False -class _KeyringStore: - """AsyncKeyValue store backed by the OS keychain (macOS Keychain, etc.). - - Tokens are encrypted at rest by the OS. Falls back to _JsonFileStore - if keyring is unavailable (headless servers, CI). - """ - - _SERVICE = "atlan-mcp" - - async def get(self, key: str, *, collection: str | None = None) -> dict[str, Any] | None: - import keyring - v = keyring.get_password(f"{self._SERVICE}/{collection or 'default'}", key) - return json.loads(v) if v else None - - async def ttl(self, key: str, *, collection: str | None = None) -> tuple[dict[str, Any] | None, float | None]: - return await self.get(key, collection=collection), None - - async def put(self, key: str, value: Mapping[str, Any], *, collection: str | None = None, ttl: Any = None) -> None: - import keyring - keyring.set_password(f"{self._SERVICE}/{collection or 'default'}", key, json.dumps(dict(value))) - - async def delete(self, key: str, *, collection: str | None = None) -> bool: - import keyring - try: - keyring.delete_password(f"{self._SERVICE}/{collection or 'default'}", key) - return True - except keyring.errors.PasswordDeleteError: - return False - - async def get_many(self, keys: list[str], *, collection: str | None = None) -> list[dict[str, Any] | None]: - return [await self.get(k, collection=collection) for k in keys] - class _JsonFileStore: """Minimal AsyncKeyValue store that persists tokens as JSON files in a directory. @@ -112,21 +79,6 @@ async def get_many(self, keys: list[str], *, collection: str | None = None) -> l return [data.get(k) for k in keys] -class _CapturingStore(_JsonFileStore): - """Intercepts FastMCP OAuth token storage to also save to our canonical keyring format.""" - - async def put(self, key: str, value: Mapping[str, Any], *, collection: str | None = None, ttl: Any = None) -> None: - await super().put(key, value, collection=collection, ttl=ttl) - v = dict(value) - if "access_token" in v: - expires_in = v.get("expires_in", 300) - _keyring_set("access_token", json.dumps({ - "access_token": v["access_token"], - "expires_at": time.time() + expires_in - 30, - })) - if "refresh_token" in v: - _keyring_set("refresh_token", str(v["refresh_token"])) - def _keyring_get(account: str) -> str | None: try: @@ -178,6 +130,15 @@ def _keyring_delete(account: str) -> None: pass +def _maybe_json(v): + """Parse v as JSON if it's a string; return v unchanged on failure.""" + if not isinstance(v, str): + return v + try: + return json.loads(v) + except (json.JSONDecodeError, ValueError): + return v + def _read_config() -> dict | None: if not _CONFIG_FILE.exists(): return None @@ -196,6 +157,8 @@ def _write_config(cfg: dict) -> None: def _wipe_credentials() -> None: """Wipe all stored credentials. Called ONLY from: refresh failure, logout, login.""" + global _resolved + _resolved = None for account in ("refresh_token", "access_token", "api_key"): _keyring_delete(account) # Clear FastMCP's internal token cache so next login --oauth always opens browser. @@ -223,7 +186,7 @@ def _resolve_auth() -> tuple[str, object]: if override_oauth: url = f"{_OAUTH_PROXY}/mcp" - store = _CapturingStore(_ATLAN_DIR / ".fastmcp-cache") + store = _JsonFileStore(_ATLAN_DIR / ".fastmcp-cache") _resolved = (url, OAuth(mcp_url=url, token_storage=store)) return _resolved @@ -240,7 +203,7 @@ def _resolve_auth() -> tuple[str, object]: if api_key and not force_oauth: _resolved = (f"{base_url}/mcp/api-key", BearerAuth(api_key)) else: - store = _CapturingStore(_ATLAN_DIR / ".fastmcp-cache") + store = _JsonFileStore(_ATLAN_DIR / ".fastmcp-cache") url = f"{base_url}/mcp" _resolved = (url, OAuth(mcp_url=url, token_storage=store)) return _resolved @@ -537,7 +500,6 @@ async def login( sys.exit(3) _keyring_set("api_key", api_key) _write_config({"auth_mode": "api-key", "tenant": tenant}) - from rich.panel import Panel console.print(Panel( f" Mode [cyan]api-key[/cyan]\n Tenant {tenant}\n\nRun [bold]atlan status[/bold] to verify connectivity.", title="[green]● Login successful[/green]", @@ -595,7 +557,6 @@ async def login( _keyring_set("refresh_token", tokens.refresh_token) _write_config({"auth_mode": "oauth", "client_id": "mcp-client"}) - from rich.panel import Panel console.print(Panel( f" Mode [cyan]oauth[/cyan]\n Proxy {_OAUTH_PROXY}/mcp\n\nRun [bold]atlan status[/bold] to check token validity.", title="[green]● Login successful[/green]", @@ -622,7 +583,6 @@ async def status() -> None: auth_mode = cfg.get("auth_mode", "unknown") - from rich.panel import Panel if auth_mode == "api-key": tenant = cfg.get("tenant", "?") @@ -682,9 +642,8 @@ async def semantic_search_tool( include_readme: Annotated[bool, cyclopts.Parameter(help="Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency.")] = False, ) -> None: '''PREFERRED search tool — use this FIRST for any discovery or lookup query. Handles natural language, fuzzy matching, typos, abbreviations, multi-word names, and glossary term lookups. Covers all asset types: tables, columns, views, schemas, dashboards, glossary terms, categories, domains, data products, and more. NOT for users or groups — use resolve_metadata_tool for those. Only fall back to search_assets_tool if you need exact attribute filtering, aggregations, or structured conditions that semantic search cannot express. This tool accepts ONLY: user_query (required), limit, offset, include_readme. It does NOT accept asset_type, conditions, search_query, or any other parameters.''' - # Parse JSON parameters - limit_parsed = json.loads(limit) if isinstance(limit, str) else limit - offset_parsed = json.loads(offset) if isinstance(offset, str) else offset + limit_parsed = _maybe_json(limit) + offset_parsed = _maybe_json(offset) await _call_tool('semantic_search_tool', {'user_query': user_query, 'limit': limit_parsed, 'offset': offset_parsed, 'include_readme': include_readme}) @@ -741,25 +700,20 @@ async def search_assets_tool( GLOSSARY TERM WORKFLOWS: - Filter terms by category: conditions={"__categories": ""}. NOTE: __categories only stores the DIRECT parent category. To include subcategories, first fetch all categories in the glossary (asset_type="AtlasGlossaryCategory", include_attributes=["qualifiedName","parentCategory"]), walk the parentCategory tree to find all descendants, then use conditions={"__categories": {"operator": "within", "value": [allDescendantQNs]}}. NOT for users/groups — use resolve_metadata_tool for those.''' - # Parse JSON parameters - conditions_parsed = json.loads(conditions) if isinstance(conditions, str) else conditions - negative_conditions_parsed = json.loads(negative_conditions) if isinstance(negative_conditions, str) else negative_conditions - some_conditions_parsed = json.loads(some_conditions) if isinstance(some_conditions, str) else some_conditions - include_attributes_parsed = json.loads(include_attributes) if isinstance(include_attributes, str) else include_attributes - asset_type_parsed = json.loads(asset_type) if isinstance(asset_type, str) else asset_type - glossary_qualified_name_parsed = json.loads(glossary_qualified_name) if isinstance(glossary_qualified_name, str) else glossary_qualified_name - sort_by_parsed = json.loads(sort_by) if isinstance(sort_by, str) else sort_by - sort_parsed = json.loads(sort) if isinstance(sort, str) else sort - connection_qualified_name_parsed = json.loads(connection_qualified_name) if isinstance(connection_qualified_name, str) else connection_qualified_name - tags_parsed = json.loads(tags) if isinstance(tags, str) else tags - domain_guids_parsed = json.loads(domain_guids) if isinstance(domain_guids, str) else domain_guids - date_range_parsed = json.loads(date_range) if isinstance(date_range, str) else date_range - guids_parsed = json.loads(guids) if isinstance(guids, str) else guids - term_guids_parsed = json.loads(term_guids) if isinstance(term_guids, str) else term_guids - aggregations_parsed = json.loads(aggregations) if isinstance(aggregations, str) else aggregations - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - - await _call_tool('search_assets_tool', {'conditions': conditions_parsed, 'negative_conditions': negative_conditions_parsed, 'some_conditions': some_conditions_parsed, 'min_somes': min_somes, 'include_attributes': include_attributes_parsed, 'asset_type': asset_type_parsed, 'glossary_qualified_name': glossary_qualified_name_parsed, 'include_archived': include_archived, 'limit': limit, 'offset': offset, 'sort_by': sort_by_parsed, 'sort_order': sort_order, 'sort': sort_parsed, 'connection_qualified_name': connection_qualified_name_parsed, 'tags': tags_parsed, 'directly_tagged': directly_tagged, 'domain_guids': domain_guids_parsed, 'date_range': date_range_parsed, 'guids': guids_parsed, 'term_guids': term_guids_parsed, 'aggregations': aggregations_parsed, 'count_only': count_only, 'scroll': scroll, 'include_readme': include_readme, 'user_query': user_query_parsed}) + conditions_parsed = _maybe_json(conditions) + negative_conditions_parsed = _maybe_json(negative_conditions) + some_conditions_parsed = _maybe_json(some_conditions) + include_attributes_parsed = _maybe_json(include_attributes) + asset_type_parsed = _maybe_json(asset_type) + sort_parsed = _maybe_json(sort) + tags_parsed = _maybe_json(tags) + domain_guids_parsed = _maybe_json(domain_guids) + date_range_parsed = _maybe_json(date_range) + guids_parsed = _maybe_json(guids) + term_guids_parsed = _maybe_json(term_guids) + aggregations_parsed = _maybe_json(aggregations) + + await _call_tool('search_assets_tool', {'conditions': conditions_parsed, 'negative_conditions': negative_conditions_parsed, 'some_conditions': some_conditions_parsed, 'min_somes': min_somes, 'include_attributes': include_attributes_parsed, 'asset_type': asset_type_parsed, 'glossary_qualified_name': glossary_qualified_name, 'include_archived': include_archived, 'limit': limit, 'offset': offset, 'sort_by': sort_by, 'sort_order': sort_order, 'sort': sort_parsed, 'connection_qualified_name': connection_qualified_name, 'tags': tags_parsed, 'directly_tagged': directly_tagged, 'domain_guids': domain_guids_parsed, 'date_range': date_range_parsed, 'guids': guids_parsed, 'term_guids': term_guids_parsed, 'aggregations': aggregations_parsed, 'count_only': count_only, 'scroll': scroll, 'include_readme': include_readme, 'user_query': user_query}) @app.command(name='traverse_lineage_tool') @@ -784,11 +738,8 @@ async def traverse_lineage_tool( supports progressive loading — leaf nodes show an expand button that fetches deeper lineage on demand. Keep size small (5-15) for responsive results; do NOT request all lineage at once.''' - # Parse JSON parameters - include_attributes_parsed = json.loads(include_attributes) if isinstance(include_attributes, str) else include_attributes - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('traverse_lineage_tool', {'guid': guid, 'direction': direction, 'depth': depth, 'size': size, 'immediate_neighbors': immediate_neighbors, 'offset': offset, 'include_attributes': include_attributes_parsed, 'user_query': user_query_parsed}) + await _call_tool('traverse_lineage_tool', {'guid': guid, 'direction': direction, 'depth': depth, 'size': size, 'immediate_neighbors': immediate_neighbors, 'offset': offset, 'include_attributes': include_attributes, 'user_query': user_query}) @app.command(name='query_assets_tool') @@ -800,11 +751,8 @@ async def query_assets_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Execute SQL queries against Atlan connections to preview data.''' - # Parse JSON parameters - default_schema_parsed = json.loads(default_schema) if isinstance(default_schema, str) else default_schema - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('query_assets_tool', {'sql': sql, 'connection_qualified_name': connection_qualified_name, 'default_schema': default_schema_parsed, 'user_query': user_query_parsed}) + await _call_tool('query_assets_tool', {'sql': sql, 'connection_qualified_name': connection_qualified_name, 'default_schema': default_schema, 'user_query': user_query}) @app.command(name='resolve_metadata_tool') @@ -816,10 +764,8 @@ async def resolve_metadata_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Use this to search for users and groups, and to get exact usernames and group names before making updates. Also use for extra discovery on classifications, business_metadata, glossary, and data_domain_and_product when semantic_search doesn\'t return needed results, or before write operations to confirm exact names and GUIDs.''' - # Parse JSON parameters - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('resolve_metadata_tool', {'namespace_type': namespace_type, 'query': query, 'limit': limit, 'user_query': user_query_parsed}) + await _call_tool('resolve_metadata_tool', {'namespace_type': namespace_type, 'query': query, 'limit': limit, 'user_query': user_query}) @app.command(name='search_atlan_docs_tool') @@ -830,10 +776,8 @@ async def search_atlan_docs_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Search Atlan\'s customer-facing documentation and return an LLM-generated answer with source citations. Use for how-to questions about Atlan features — not for searching data assets (use semantic_search_tool for that).''' - # Parse JSON parameters - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('search_atlan_docs_tool', {'query': query, 'top_k': top_k, 'user_query': user_query_parsed}) + await _call_tool('search_atlan_docs_tool', {'query': query, 'top_k': top_k, 'user_query': user_query}) @app.command(name='get_groups_tool') @@ -847,12 +791,8 @@ async def get_groups_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Get workspace groups and their members. Use include_members=True to list users in a group.''' - # Parse JSON parameters - name_filter_parsed = json.loads(name_filter) if isinstance(name_filter, str) else name_filter - group_id_parsed = json.loads(group_id) if isinstance(group_id, str) else group_id - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('get_groups_tool', {'name_filter': name_filter_parsed, 'group_id': group_id_parsed, 'include_members': include_members, 'limit': limit, 'offset': offset, 'user_query': user_query_parsed}) + await _call_tool('get_groups_tool', {'name_filter': name_filter, 'group_id': group_id, 'include_members': include_members, 'limit': limit, 'offset': offset, 'user_query': user_query}) @app.command(name='get_asset_tool') @@ -867,14 +807,8 @@ async def get_asset_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Get detailed information about a single asset by its GUID or qualified name.''' - # Parse JSON parameters - guid_parsed = json.loads(guid) if isinstance(guid, str) else guid - qualified_name_parsed = json.loads(qualified_name) if isinstance(qualified_name, str) else qualified_name - asset_type_parsed = json.loads(asset_type) if isinstance(asset_type, str) else asset_type - include_attributes_parsed = json.loads(include_attributes) if isinstance(include_attributes, str) else include_attributes - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('get_asset_tool', {'guid': guid_parsed, 'qualified_name': qualified_name_parsed, 'asset_type': asset_type_parsed, 'include_attributes': include_attributes_parsed, 'include_dq_checks': include_dq_checks, 'include_readme': include_readme, 'user_query': user_query_parsed}) + await _call_tool('get_asset_tool', {'guid': guid, 'qualified_name': qualified_name, 'asset_type': asset_type, 'include_attributes': include_attributes, 'include_dq_checks': include_dq_checks, 'include_readme': include_readme, 'user_query': user_query}) @app.command(name='update_assets_tool') @@ -885,11 +819,9 @@ async def update_assets_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Update attributes on one or more assets. Each item specifies the asset identity + fields to change. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - updates_parsed = json.loads(updates) if isinstance(updates, str) else updates - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + updates_parsed = _maybe_json(updates) - await _call_tool('update_assets_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('update_assets_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='create_glossaries') @@ -900,11 +832,9 @@ async def create_glossaries( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Create one or more glossaries in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - glossaries_parsed = json.loads(glossaries) if isinstance(glossaries, str) else glossaries - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + glossaries_parsed = _maybe_json(glossaries) - await _call_tool('create_glossaries', {'glossaries': glossaries_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('create_glossaries', {'glossaries': glossaries_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='create_glossary_terms') @@ -915,11 +845,9 @@ async def create_glossary_terms( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Create one or more glossary terms in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - terms_parsed = json.loads(terms) if isinstance(terms, str) else terms - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + terms_parsed = _maybe_json(terms) - await _call_tool('create_glossary_terms', {'terms': terms_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('create_glossary_terms', {'terms': terms_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='create_glossary_categories') @@ -930,11 +858,9 @@ async def create_glossary_categories( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Create one or more glossary categories in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - categories_parsed = json.loads(categories) if isinstance(categories, str) else categories - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + categories_parsed = _maybe_json(categories) - await _call_tool('create_glossary_categories', {'categories': categories_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('create_glossary_categories', {'categories': categories_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='create_domains') @@ -945,11 +871,9 @@ async def create_domains( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Create one or more data domains in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - domains_parsed = json.loads(domains) if isinstance(domains, str) else domains - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + domains_parsed = _maybe_json(domains) - await _call_tool('create_domains', {'domains': domains_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('create_domains', {'domains': domains_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='create_data_products') @@ -960,11 +884,9 @@ async def create_data_products( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Create one or more data products in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - products_parsed = json.loads(products) if isinstance(products, str) else products - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + products_parsed = _maybe_json(products) - await _call_tool('create_data_products', {'products': products_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('create_data_products', {'products': products_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='create_dq_rules_tool') @@ -975,11 +897,9 @@ async def create_dq_rules_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Create data quality rules in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - rules_parsed = json.loads(rules) if isinstance(rules, str) else rules - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + rules_parsed = _maybe_json(rules) - await _call_tool('create_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('create_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='schedule_dq_rules_tool') @@ -990,11 +910,9 @@ async def schedule_dq_rules_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Schedule data quality rule execution on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - schedules_parsed = json.loads(schedules) if isinstance(schedules, str) else schedules - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + schedules_parsed = _maybe_json(schedules) - await _call_tool('schedule_dq_rules_tool', {'schedules': schedules_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('schedule_dq_rules_tool', {'schedules': schedules_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='update_dq_rules_tool') @@ -1005,11 +923,9 @@ async def update_dq_rules_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Update existing data quality rules. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - rules_parsed = json.loads(rules) if isinstance(rules, str) else rules - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + rules_parsed = _maybe_json(rules) - await _call_tool('update_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('update_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='delete_dq_rules_tool') @@ -1020,11 +936,9 @@ async def delete_dq_rules_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Delete data quality rules by their GUIDs. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - rule_guids_parsed = json.loads(rule_guids) if isinstance(rule_guids, str) else rule_guids - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + rule_guids_parsed = _maybe_json(rule_guids) - await _call_tool('delete_dq_rules_tool', {'rule_guids': rule_guids_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('delete_dq_rules_tool', {'rule_guids': rule_guids_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='manage_asset_lifecycle_tool') @@ -1038,13 +952,8 @@ async def manage_asset_lifecycle_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Manage asset lifecycle: archive, restore, or permanently purge assets. WARNING: PURGE cannot be undone. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - guids_parsed = json.loads(guids) if isinstance(guids, str) else guids - asset_type_parsed = json.loads(asset_type) if isinstance(asset_type, str) else asset_type - qualified_name_parsed = json.loads(qualified_name) if isinstance(qualified_name, str) else qualified_name - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('manage_asset_lifecycle_tool', {'operation': operation, 'guids': guids_parsed, 'asset_type': asset_type_parsed, 'qualified_name': qualified_name_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('manage_asset_lifecycle_tool', {'operation': operation, 'guids': guids, 'asset_type': asset_type, 'qualified_name': qualified_name, 'mode': mode, 'user_query': user_query}) @app.command(name='manage_announcements_tool') @@ -1059,13 +968,8 @@ async def manage_announcements_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Add or remove announcements (information, warning, issue) on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - announcement_type_parsed = json.loads(announcement_type) if isinstance(announcement_type, str) else announcement_type - title_parsed = json.loads(title) if isinstance(title, str) else title - message_parsed = json.loads(message) if isinstance(message, str) else message - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('manage_announcements_tool', {'asset_guids': asset_guids, 'operation': operation, 'announcement_type': announcement_type_parsed, 'title': title_parsed, 'message': message_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('manage_announcements_tool', {'asset_guids': asset_guids, 'operation': operation, 'announcement_type': announcement_type, 'title': title, 'message': message, 'mode': mode, 'user_query': user_query}) @app.command(name='update_custom_metadata_tool') @@ -1077,11 +981,9 @@ async def update_custom_metadata_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Update custom metadata on one or more assets. Set replace=True for full replacement, False (default) for partial update. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - updates_parsed = json.loads(updates) if isinstance(updates, str) else updates - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + updates_parsed = _maybe_json(updates) - await _call_tool('update_custom_metadata_tool', {'updates': updates_parsed, 'replace': replace, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('update_custom_metadata_tool', {'updates': updates_parsed, 'replace': replace, 'mode': mode, 'user_query': user_query}) @app.command(name='remove_custom_metadata_tool') @@ -1093,10 +995,8 @@ async def remove_custom_metadata_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Remove a custom metadata set from an asset. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query - await _call_tool('remove_custom_metadata_tool', {'guid': guid, 'custom_metadata_name': custom_metadata_name, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('remove_custom_metadata_tool', {'guid': guid, 'custom_metadata_name': custom_metadata_name, 'mode': mode, 'user_query': user_query}) @app.command(name='create_custom_metadata_set_tool') @@ -1107,11 +1007,9 @@ async def create_custom_metadata_set_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Create one or more custom metadata sets with typed attributes. Defines the schema; use update_custom_metadata to set values on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - sets_parsed = json.loads(sets) if isinstance(sets, str) else sets - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + sets_parsed = _maybe_json(sets) - await _call_tool('create_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('create_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='delete_custom_metadata_set_tool') @@ -1122,11 +1020,9 @@ async def delete_custom_metadata_set_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Permanently delete one or more custom metadata sets and all their attribute values from assets. Irreversible. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - sets_parsed = json.loads(sets) if isinstance(sets, str) else sets - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + sets_parsed = _maybe_json(sets) - await _call_tool('delete_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('delete_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='add_attributes_to_cm_set_tool') @@ -1138,11 +1034,9 @@ async def add_attributes_to_cm_set_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Add new typed attributes to an existing custom metadata set. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - attributes_parsed = json.loads(attributes) if isinstance(attributes, str) else attributes - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + attributes_parsed = _maybe_json(attributes) - await _call_tool('add_attributes_to_cm_set_tool', {'display_name': display_name, 'attributes': attributes_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('add_attributes_to_cm_set_tool', {'display_name': display_name, 'attributes': attributes_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='remove_attributes_from_cm_set_tool') @@ -1154,11 +1048,9 @@ async def remove_attributes_from_cm_set_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Remove (archive) attributes from an existing custom metadata set. Archived attributes are soft-deleted and their values cleared from all assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - attribute_names_parsed = json.loads(attribute_names) if isinstance(attribute_names, str) else attribute_names - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + attribute_names_parsed = _maybe_json(attribute_names) - await _call_tool('remove_attributes_from_cm_set_tool', {'display_name': display_name, 'attribute_names': attribute_names_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('remove_attributes_from_cm_set_tool', {'display_name': display_name, 'attribute_names': attribute_names_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='add_atlan_tags_tool') @@ -1169,11 +1061,9 @@ async def add_atlan_tags_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Add Atlan tags to one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - updates_parsed = json.loads(updates) if isinstance(updates, str) else updates - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + updates_parsed = _maybe_json(updates) - await _call_tool('add_atlan_tags_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('add_atlan_tags_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='remove_atlan_tag_tool') @@ -1184,11 +1074,9 @@ async def remove_atlan_tag_tool( user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, ) -> None: '''Remove an Atlan tag from one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - # Parse JSON parameters - updates_parsed = json.loads(updates) if isinstance(updates, str) else updates - user_query_parsed = json.loads(user_query) if isinstance(user_query, str) else user_query + updates_parsed = _maybe_json(updates) - await _call_tool('remove_atlan_tag_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query_parsed}) + await _call_tool('remove_atlan_tag_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query}) @app.command(name='get_asset_icons') From 88ac2153df64ff945b9e61d52d9b0a6895d9abeb Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 16:09:37 +0530 Subject: [PATCH 18/26] fix(mcp-cli): replace questionary with rich.prompt for interactive login questionary was not installed (not in deps), causing atlan login to silently skip the chooser and go straight to OAuth. Now uses rich.prompt (already a required dep) for the method selector and masked API key input. Co-Authored-By: Claude Sonnet 4.6 --- mcp-cli/atlan_cli.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 2bc60d9..afa402b 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -510,25 +510,25 @@ async def login( use_oauth = oauth or bool(os.environ.get("_ATLAN_OVERRIDE_OAUTH")) if not use_oauth: - # Interactive chooser — falls back to OAuth if questionary not installed. + from rich.prompt import Prompt + console.print("\n[bold]Choose login method:[/bold]") + console.print(" [cyan]1[/cyan] OAuth — browser login via mcp.atlan.com") + console.print(" [cyan]2[/cyan] API key — paste your Atlan API key\n") try: - import questionary # type: ignore - choice = questionary.select( - "How would you like to log in?", - choices=["OAuth — browser login via mcp.atlan.com", "API key — paste your Atlan API key"], - ).ask() - if choice is None: + choice = Prompt.ask("Choice", choices=["1", "2"], default="1") + except (KeyboardInterrupt, EOFError): + sys.exit(0) + if choice == "2": + try: + api_key_in = Prompt.ask("API key", password=True) + tenant_in = Prompt.ask("Tenant URL (e.g. https://demo.atlan.com)") + except (KeyboardInterrupt, EOFError): sys.exit(0) - if choice == "API key — paste your Atlan API key": - api_key_in = questionary.password("API key:").ask() - tenant_in = questionary.text("Tenant URL (e.g. https://demo.atlan.com):").ask() - if not api_key_in or not tenant_in: - _err("API key and tenant are required.") - sys.exit(3) - await login(oauth=False, api_key=api_key_in, tenant=tenant_in) - return - except ImportError: - pass + if not api_key_in or not tenant_in: + _err("API key and tenant are required.") + sys.exit(3) + await login(oauth=False, api_key=api_key_in, tenant=tenant_in) + return use_oauth = True # OAuth browser flow. From 25c40a42afd9cd33bfa4db9d8e4eb1866af0e1a3 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 16:36:34 +0530 Subject: [PATCH 19/26] fix(mcp-cli): use input() for API key prompt to avoid getpass hang getpass.getpass() reads char-by-char in raw terminal mode and stalls visibly when pasting long JWT tokens (1000+ chars). input() uses the terminal's own line-edit mode and handles pastes instantly. Co-Authored-By: Claude Sonnet 4.6 --- mcp-cli/atlan_cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index afa402b..09ab65f 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -520,8 +520,10 @@ async def login( sys.exit(0) if choice == "2": try: - api_key_in = Prompt.ask("API key", password=True) - tenant_in = Prompt.ask("Tenant URL (e.g. https://demo.atlan.com)") + # Use input() not getpass — getpass reads char-by-char in raw mode + # and stalls on long pasted JWTs (1000+ chars). + api_key_in = input(" API key: ").strip() + tenant_in = input(" Tenant URL (e.g. https://demo.atlan.com): ").strip() except (KeyboardInterrupt, EOFError): sys.exit(0) if not api_key_in or not tenant_in: From 74e72846e8dc7a331feb7b144196a7ed091152ca Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 16:41:56 +0530 Subject: [PATCH 20/26] chore(mcp-cli): fix publish workflow, gitignore, and README polish - workflow: allow workflow_dispatch (was blocked by tags-only if condition) - gitignore: add plugin build artifact patterns (*.tar.gz, *.zip) - README: add PyPI install snippet, document interactive login flow with example Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mcp-cli-publish.yaml | 2 +- .gitignore | 4 ++++ mcp-cli/README.md | 23 ++++++++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mcp-cli-publish.yaml b/.github/workflows/mcp-cli-publish.yaml index 8e84202..160a281 100644 --- a/.github/workflows/mcp-cli-publish.yaml +++ b/.github/workflows/mcp-cli-publish.yaml @@ -10,7 +10,7 @@ permissions: jobs: publish: - if: startsWith(github.ref, 'refs/tags/') + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest defaults: run: diff --git a/.gitignore b/.gitignore index b1a0109..aa93d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,7 @@ cython_debug/ .vscode/ .DS_Store + +# Plugin build artifacts +atlan-claude-plugin.tar.gz +atlan-claude-plugin.zip diff --git a/mcp-cli/README.md b/mcp-cli/README.md index 6ef95ca..cd747af 100644 --- a/mcp-cli/README.md +++ b/mcp-cli/README.md @@ -7,6 +7,10 @@ A standalone CLI for calling Atlan MCP tools directly from your terminal — no Install [`uv`](https://docs.astral.sh/uv/getting-started/installation/) if you don't have it, then install the CLI as a global tool: ```bash +# From PyPI (once published) +uv tool install atlan-cli + +# From source uv tool install /path/to/agent-toolkit/mcp-cli ``` @@ -19,11 +23,12 @@ export PATH="$HOME/.local/bin:$PATH" ## Quick Start ```bash -# One-time login — opens browser (OAuth via mcp.atlan.com) +# One-time login — interactive: choose OAuth or API key atlan login -# Or log in with an API key -atlan login --api-key sk-xxx --tenant https://your-tenant.atlan.com +# Or skip the prompt and log in directly +atlan login --oauth # browser login +atlan login --api-key sk-xxx --tenant https://your-tenant.atlan.com # API key # Check your auth status atlan status @@ -34,6 +39,18 @@ atlan list-tools atlan get_asset_tool --guid "abc-123" ``` +`atlan login` with no flags shows an interactive prompt: + +``` +Choose login method: + 1 OAuth — browser login via mcp.atlan.com + 2 API key — paste your Atlan API key + +Choice [1/2] (1): 2 + API key: + Tenant URL (e.g. https://demo.atlan.com): https://your-tenant.atlan.com +``` + ## Auth Commands | Command | Description | From 8be7be32f60caf9d51755f4034814a3f672d0403 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 17:12:21 +0530 Subject: [PATCH 21/26] style(mcp-cli): fix ruff lint and format (f-strings, formatting) Three F541 bare f-strings and one formatting inconsistency flagged by pre-commit ruff hooks; auto-fixed with ruff --fix + ruff format. Co-Authored-By: Claude Sonnet 4.6 --- mcp-cli/atlan_cli.py | 1436 +++++++++++++++++++++++++++++++++--------- 1 file changed, 1132 insertions(+), 304 deletions(-) diff --git a/mcp-cli/atlan_cli.py b/mcp-cli/atlan_cli.py index 09ab65f..6ea149a 100755 --- a/mcp-cli/atlan_cli.py +++ b/mcp-cli/atlan_cli.py @@ -32,7 +32,6 @@ _JSON_MODE = False - class _JsonFileStore: """Minimal AsyncKeyValue store that persists tokens as JSON files in a directory. @@ -55,13 +54,24 @@ def _load(self, collection: str) -> dict: def _save(self, collection: str, data: dict) -> None: self._file(collection).write_text(json.dumps(data)) - async def get(self, key: str, *, collection: str | None = None) -> dict[str, Any] | None: + async def get( + self, key: str, *, collection: str | None = None + ) -> dict[str, Any] | None: return self._load(collection or "default").get(key) - async def ttl(self, key: str, *, collection: str | None = None) -> tuple[dict[str, Any] | None, float | None]: + async def ttl( + self, key: str, *, collection: str | None = None + ) -> tuple[dict[str, Any] | None, float | None]: return self._load(collection or "default").get(key), None - async def put(self, key: str, value: Mapping[str, Any], *, collection: str | None = None, ttl: Any = None) -> None: + async def put( + self, + key: str, + value: Mapping[str, Any], + *, + collection: str | None = None, + ttl: Any = None, + ) -> None: data = self._load(collection or "default") data[key] = dict(value) self._save(collection or "default", data) @@ -74,15 +84,17 @@ async def delete(self, key: str, *, collection: str | None = None) -> bool: return True return False - async def get_many(self, keys: list[str], *, collection: str | None = None) -> list[dict[str, Any] | None]: + async def get_many( + self, keys: list[str], *, collection: str | None = None + ) -> list[dict[str, Any] | None]: data = self._load(collection or "default") return [data.get(k) for k in keys] - def _keyring_get(account: str) -> str | None: try: import keyring as kr + val = kr.get_password("atlan-mcp", account) if val is not None: return val @@ -99,6 +111,7 @@ def _keyring_get(account: str) -> str | None: def _keyring_set(account: str, value: str) -> None: try: import keyring as kr + kr.set_password("atlan-mcp", account, value) return except Exception: @@ -118,6 +131,7 @@ def _keyring_set(account: str, value: str) -> None: def _keyring_delete(account: str) -> None: try: import keyring as kr + kr.delete_password("atlan-mcp", account) except Exception: pass @@ -139,13 +153,16 @@ def _maybe_json(v): except (json.JSONDecodeError, ValueError): return v + def _read_config() -> dict | None: if not _CONFIG_FILE.exists(): return None try: return json.loads(_CONFIG_FILE.read_text()) except Exception: - _stderr.print("[bold red]Error:[/bold red] Malformed ~/.atlan/config.json. Run [cyan]atlan login[/cyan].") + _stderr.print( + "[bold red]Error:[/bold red] Malformed ~/.atlan/config.json. Run [cyan]atlan login[/cyan]." + ) sys.exit(3) @@ -163,6 +180,7 @@ def _wipe_credentials() -> None: _keyring_delete(account) # Clear FastMCP's internal token cache so next login --oauth always opens browser. import shutil + cache = _ATLAN_DIR / ".fastmcp-cache" if cache.exists(): shutil.rmtree(cache) @@ -213,7 +231,9 @@ def _resolve_auth() -> tuple[str, object]: if auth_mode == "api-key": tenant = cfg.get("tenant", "").rstrip("/") if not tenant: - _err("Config error: api-key mode requires tenant URL. Run [cyan]atlan login[/cyan].") + _err( + "Config error: api-key mode requires tenant URL. Run [cyan]atlan login[/cyan]." + ) sys.exit(3) api_key = _keyring_get("api_key") if not api_key: @@ -228,7 +248,10 @@ def _resolve_auth() -> tuple[str, object]: try: at_data = json.loads(at_raw) if time.time() < at_data.get("expires_at", 0): - _resolved = (f"{_OAUTH_PROXY}/mcp", BearerAuth(at_data["access_token"])) + _resolved = ( + f"{_OAUTH_PROXY}/mcp", + BearerAuth(at_data["access_token"]), + ) return _resolved except Exception: pass @@ -239,6 +262,7 @@ def _resolve_auth() -> tuple[str, object]: sys.exit(2) import httpx + try: resp = httpx.post( f"{_OAUTH_PROXY}/oauth/token", @@ -263,10 +287,15 @@ def _resolve_auth() -> tuple[str, object]: data = resp.json() access_token = data["access_token"] expires_in = data.get("expires_in", 300) - _keyring_set("access_token", json.dumps({ - "access_token": access_token, - "expires_at": time.time() + expires_in - 30, - })) + _keyring_set( + "access_token", + json.dumps( + { + "access_token": access_token, + "expires_at": time.time() + expires_in - 30, + } + ), + ) if "refresh_token" in data: _keyring_set("refresh_token", data["refresh_token"]) @@ -276,6 +305,7 @@ def _resolve_auth() -> tuple[str, object]: _err(f"Unknown auth_mode {auth_mode!r}. Run [cyan]atlan login[/cyan].") sys.exit(3) + def _client() -> "Client": url, auth = _resolve_auth() return Client(url, auth=auth) @@ -357,6 +387,7 @@ async def _call_tool(tool_name: str, arguments: dict) -> None: async def list_tools() -> None: """List available tools.""" from rich.table import Table + async with _client() as client: tools = await client.list_tools() if not tools: @@ -369,10 +400,7 @@ async def list_tools() -> None: for tool in tools: props = tool.inputSchema.get("properties", {}) required = set(tool.inputSchema.get("required", [])) - params = ", ".join( - f"[bold]{n}[/bold]" if n in required else n - for n in props - ) + params = ", ".join(f"[bold]{n}[/bold]" if n in required else n for n in props) table.add_row(tool.name, tool.description or "", params) console.print(table) console.print(f"\n[dim]Bold = required · {len(tools)} tools total[/dim]") @@ -396,7 +424,9 @@ async def list_resources() -> None: @app.command -async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource URI")]) -> None: +async def read_resource( + uri: Annotated[str, cyclopts.Parameter(help="Resource URI")], +) -> None: """Read a resource by URI.""" async with _client() as client: contents = await client.read_resource(uri) @@ -436,7 +466,9 @@ async def get_prompt( parsed: dict[str, str] = {} for arg in arguments: if "=" not in arg: - console.print(f"[bold red]Error:[/bold red] Invalid argument {arg!r} — expected key=value") + console.print( + f"[bold red]Error:[/bold red] Invalid argument {arg!r} — expected key=value" + ) sys.exit(1) key, value = arg.split("=", 1) parsed[key] = value @@ -449,7 +481,9 @@ async def get_prompt( console.print(f" {msg.content.text}") elif isinstance(msg.content, mcp.types.ImageContent): size = len(msg.content.data) * 3 // 4 - console.print(f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]") + console.print( + f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]" + ) else: console.print(f" {msg.content}") console.print() @@ -463,9 +497,16 @@ async def get_prompt( @app.command async def login( *, - oauth: Annotated[bool, cyclopts.Parameter("--oauth", help="Force OAuth browser login")] = False, - api_key: Annotated[str | None, cyclopts.Parameter("--api-key", help="Atlan API key")] = None, - tenant: Annotated[str | None, cyclopts.Parameter("--tenant", help="Tenant URL (required with --api-key)")] = None, + oauth: Annotated[ + bool, cyclopts.Parameter("--oauth", help="Force OAuth browser login") + ] = False, + api_key: Annotated[ + str | None, cyclopts.Parameter("--api-key", help="Atlan API key") + ] = None, + tenant: Annotated[ + str | None, + cyclopts.Parameter("--tenant", help="Tenant URL (required with --api-key)"), + ] = None, ) -> None: """Authenticate with Atlan. Run once; tool calls reuse stored credentials. @@ -485,10 +526,14 @@ async def login( if api_key and tenant: tenant = tenant.rstrip("/") import httpx + try: resp = httpx.get( f"{tenant}/api/meta/types/typedefs", - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, timeout=10, follow_redirects=True, ) @@ -496,21 +541,26 @@ async def login( _err(f"Network error validating API key: {exc}") sys.exit(3) if resp.status_code in (401, 403): - _err(f"API key validation failed (HTTP {resp.status_code}). Check key and tenant URL.") + _err( + f"API key validation failed (HTTP {resp.status_code}). Check key and tenant URL." + ) sys.exit(3) _keyring_set("api_key", api_key) _write_config({"auth_mode": "api-key", "tenant": tenant}) - console.print(Panel( - f" Mode [cyan]api-key[/cyan]\n Tenant {tenant}\n\nRun [bold]atlan status[/bold] to verify connectivity.", - title="[green]● Login successful[/green]", - expand=False, - )) + console.print( + Panel( + f" Mode [cyan]api-key[/cyan]\n Tenant {tenant}\n\nRun [bold]atlan status[/bold] to verify connectivity.", + title="[green]● Login successful[/green]", + expand=False, + ) + ) return use_oauth = oauth or bool(os.environ.get("_ATLAN_OVERRIDE_OAUTH")) if not use_oauth: from rich.prompt import Prompt + console.print("\n[bold]Choose login method:[/bold]") console.print(" [cyan]1[/cyan] OAuth — browser login via mcp.atlan.com") console.print(" [cyan]2[/cyan] API key — paste your Atlan API key\n") @@ -523,7 +573,9 @@ async def login( # Use input() not getpass — getpass reads char-by-char in raw mode # and stalls on long pasted JWTs (1000+ chars). api_key_in = input(" API key: ").strip() - tenant_in = input(" Tenant URL (e.g. https://demo.atlan.com): ").strip() + tenant_in = input( + " Tenant URL (e.g. https://demo.atlan.com): " + ).strip() except (KeyboardInterrupt, EOFError): sys.exit(0) if not api_key_in or not tenant_in: @@ -551,19 +603,26 @@ async def login( tokens = await auth.token_storage_adapter.get_tokens() if tokens and tokens.access_token: expires_at = time.time() + (tokens.expires_in or 300) - 30 - _keyring_set("access_token", json.dumps({ - "access_token": tokens.access_token, - "expires_at": expires_at, - })) + _keyring_set( + "access_token", + json.dumps( + { + "access_token": tokens.access_token, + "expires_at": expires_at, + } + ), + ) if tokens.refresh_token: _keyring_set("refresh_token", tokens.refresh_token) _write_config({"auth_mode": "oauth", "client_id": "mcp-client"}) - console.print(Panel( - f" Mode [cyan]oauth[/cyan]\n Proxy {_OAUTH_PROXY}/mcp\n\nRun [bold]atlan status[/bold] to check token validity.", - title="[green]● Login successful[/green]", - expand=False, - )) + console.print( + Panel( + f" Mode [cyan]oauth[/cyan]\n Proxy {_OAUTH_PROXY}/mcp\n\nRun [bold]atlan status[/bold] to check token validity.", + title="[green]● Login successful[/green]", + expand=False, + ) + ) @app.command @@ -572,7 +631,9 @@ async def logout() -> None: _wipe_credentials() if _CONFIG_FILE.exists(): _CONFIG_FILE.unlink() - console.print("[green]Logged out.[/green] Run [cyan]atlan login[/cyan] to authenticate again.") + console.print( + "[green]Logged out.[/green] Run [cyan]atlan login[/cyan] to authenticate again." + ) @app.command @@ -585,16 +646,21 @@ async def status() -> None: auth_mode = cfg.get("auth_mode", "unknown") - if auth_mode == "api-key": tenant = cfg.get("tenant", "?") has_key = bool(_keyring_get("api_key")) if has_key: lines = [ - f" Mode [cyan]api-key[/cyan]", + " Mode [cyan]api-key[/cyan]", f" Tenant {tenant}", ] - console.print(Panel("\n".join(lines), title="[green]● Authenticated[/green]", expand=False)) + console.print( + Panel( + "\n".join(lines), + title="[green]● Authenticated[/green]", + expand=False, + ) + ) else: _err("Config present but API key missing. Run [cyan]atlan login[/cyan].") sys.exit(2) @@ -608,21 +674,29 @@ async def status() -> None: remaining = at_data.get("expires_at", 0) - time.time() if remaining > 0: lines = [ - f" Mode [cyan]oauth[/cyan]", + " Mode [cyan]oauth[/cyan]", f" Proxy {_OAUTH_PROXY}/mcp", f" Token expires in [cyan]{int(remaining)}s[/cyan]", f" Refresh {'[green]present[/green]' if rt else '[red]missing[/red]'}", ] - console.print(Panel("\n".join(lines), title="[green]● Authenticated[/green]", expand=False)) + console.print( + Panel( + "\n".join(lines), + title="[green]● Authenticated[/green]", + expand=False, + ) + ) return except Exception: pass if rt: - console.print(Panel( - f" Mode [cyan]oauth[/cyan]\n Token [yellow]expired[/yellow] — will auto-refresh on next call\n Refresh [green]present[/green]", - title="[yellow]● Token Expired[/yellow]", - expand=False, - )) + console.print( + Panel( + " Mode [cyan]oauth[/cyan]\n Token [yellow]expired[/yellow] — will auto-refresh on next call\n Refresh [green]present[/green]", + title="[yellow]● Token Expired[/yellow]", + expand=False, + ) + ) else: _err("Not authenticated. Run [cyan]atlan login[/cyan].") sys.exit(2) @@ -635,73 +709,201 @@ async def status() -> None: # Tool commands (generated from server schema) # --------------------------------------------------------------------------- -@app.command(name='semantic_search_tool') + +@app.command(name="semantic_search_tool") async def semantic_search_tool( *, - user_query: Annotated[str, cyclopts.Parameter(help="Natural language search query")], - limit: Annotated[str | None, cyclopts.Parameter(help="Max results to return (default 10, max 20)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"integer\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Max results to return (default 10, max 20)\"\n }")] = None, - offset: Annotated[str | None, cyclopts.Parameter(help="Skip first N results for pagination\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"integer\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Skip first N results for pagination\"\n }")] = None, - include_readme: Annotated[bool, cyclopts.Parameter(help="Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency.")] = False, + user_query: Annotated[ + str, cyclopts.Parameter(help="Natural language search query") + ], + limit: Annotated[ + str | None, + cyclopts.Parameter( + help='Max results to return (default 10, max 20)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "integer"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Max results to return (default 10, max 20)"\n }' + ), + ] = None, + offset: Annotated[ + str | None, + cyclopts.Parameter( + help='Skip first N results for pagination\\nJSON Schema: {\n "anyOf": [\n {\n "type": "integer"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Skip first N results for pagination"\n }' + ), + ] = None, + include_readme: Annotated[ + bool, + cyclopts.Parameter( + help="Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency." + ), + ] = False, ) -> None: - '''PREFERRED search tool — use this FIRST for any discovery or lookup query. Handles natural language, fuzzy matching, typos, abbreviations, multi-word names, and glossary term lookups. Covers all asset types: tables, columns, views, schemas, dashboards, glossary terms, categories, domains, data products, and more. NOT for users or groups — use resolve_metadata_tool for those. Only fall back to search_assets_tool if you need exact attribute filtering, aggregations, or structured conditions that semantic search cannot express. This tool accepts ONLY: user_query (required), limit, offset, include_readme. It does NOT accept asset_type, conditions, search_query, or any other parameters.''' + """PREFERRED search tool — use this FIRST for any discovery or lookup query. Handles natural language, fuzzy matching, typos, abbreviations, multi-word names, and glossary term lookups. Covers all asset types: tables, columns, views, schemas, dashboards, glossary terms, categories, domains, data products, and more. NOT for users or groups — use resolve_metadata_tool for those. Only fall back to search_assets_tool if you need exact attribute filtering, aggregations, or structured conditions that semantic search cannot express. This tool accepts ONLY: user_query (required), limit, offset, include_readme. It does NOT accept asset_type, conditions, search_query, or any other parameters.""" limit_parsed = _maybe_json(limit) offset_parsed = _maybe_json(offset) - await _call_tool('semantic_search_tool', {'user_query': user_query, 'limit': limit_parsed, 'offset': offset_parsed, 'include_readme': include_readme}) + await _call_tool( + "semantic_search_tool", + { + "user_query": user_query, + "limit": limit_parsed, + "offset": offset_parsed, + "include_readme": include_readme, + }, + ) -@app.command(name='query_deep_sql_tool') +@app.command(name="query_deep_sql_tool") async def query_deep_sql_tool( *, - sql: Annotated[str, cyclopts.Parameter(help="SELECT SQL query to execute against the Gold Layer.")], + sql: Annotated[ + str, + cyclopts.Parameter(help="SELECT SQL query to execute against the Gold Layer."), + ], limit: Annotated[int, cyclopts.Parameter(help="Max rows to return.")] = 50, offset: Annotated[int, cyclopts.Parameter(help="Row offset for pagination.")] = 0, ) -> None: - '''Execute Gold Layer SQL for pagination in the deep query results widget. Internal tool.''' - await _call_tool('query_deep_sql_tool', {'sql': sql, 'limit': limit, 'offset': offset}) + """Execute Gold Layer SQL for pagination in the deep query results widget. Internal tool.""" + await _call_tool( + "query_deep_sql_tool", {"sql": sql, "limit": limit, "offset": offset} + ) -@app.command(name='search_assets_tool') +@app.command(name="search_assets_tool") async def search_assets_tool( *, - conditions: Annotated[str | None, cyclopts.Parameter(help="Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {\"__categories\": \"\"} (direct only) or {\"__categories\": {\"operator\": \"within\", \"value\": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {\\\"__categories\\\": \\\"\\\"} (direct only) or {\\\"__categories\\\": {\\\"operator\\\": \\\"within\\\", \\\"value\\\": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.\",\n \"examples\": [\n {\n \"name\": \"customers\"\n },\n {\n \"name\": {\n \"operator\": \"startswith\",\n \"value\": \"dim_\"\n }\n },\n {\n \"certificate_status\": \"VERIFIED\"\n },\n {\n \"__typeName\": \"Table\"\n },\n {\n \"__categories\": \"\"\n },\n {\n \"__categories\": {\n \"operator\": \"within\",\n \"value\": [\n \"\",\n \"\",\n \"\"\n ]\n }\n }\n ]\n }")] = None, - negative_conditions: Annotated[str | None, cyclopts.Parameter(help="Exclusion filters (same format as conditions)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Exclusion filters (same format as conditions)\",\n \"examples\": [\n {\n \"certificate_status\": \"DEPRECATED\"\n },\n {\n \"name\": {\n \"operator\": \"contains\",\n \"value\": \"test\"\n }\n }\n ]\n }")] = None, - some_conditions: Annotated[str | None, cyclopts.Parameter(help="OR-style filters requiring min_somes matches\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"OR-style filters requiring min_somes matches\",\n \"examples\": [\n {\n \"owner_groups\": \"admins\",\n \"owner_users\": \"user1\"\n }\n ]\n }")] = None, - min_somes: Annotated[int, cyclopts.Parameter(help="Minimum some_conditions to match")] = 1, - include_attributes: Annotated[str | None, cyclopts.Parameter(help="Attributes to return (e.g., owner_users, columns, readme). For category hierarchy traversal include parentCategory and qualifiedName.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Attributes to return (e.g., owner_users, columns, readme). For category hierarchy traversal include parentCategory and qualifiedName.\"\n }")] = None, - asset_type: Annotated[str | None, cyclopts.Parameter(help="Asset type(s) to search. Single type or list of types.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"enum\": [\n \"Table\",\n \"Column\",\n \"View\",\n \"Database\",\n \"Schema\",\n \"MaterializedView\",\n \"AtlasGlossary\",\n \"AtlasGlossaryTerm\",\n \"AtlasGlossaryCategory\",\n \"Connection\",\n \"Process\",\n \"Query\",\n \"Dashboard\",\n \"Report\",\n \"DataDomain\",\n \"DataProduct\",\n \"PowerBIReport\",\n \"PowerBIDataset\",\n \"PowerBIDashboard\",\n \"PowerBIWorkspace\",\n \"PowerBIDataflow\",\n \"PowerBITable\",\n \"PowerBIMeasure\",\n \"PowerBIColumn\",\n \"PowerBIPage\",\n \"DbtModel\",\n \"DbtTest\",\n \"DbtSource\",\n \"DbtMetric\",\n \"DbtModelColumn\",\n \"TableauDashboard\",\n \"TableauCalculatedField\",\n \"TableauWorkbook\",\n \"TableauDatasource\",\n \"TableauProject\",\n \"TableauWorksheet\",\n \"TableauSite\",\n \"TableauFlow\",\n \"LookerDashboard\",\n \"LookerExplore\",\n \"LookerLook\",\n \"LookerView\",\n \"LookerModel\",\n \"LookerProject\",\n \"LookerQuery\",\n \"LookerFolder\",\n \"LookerTile\",\n \"LookerField\",\n \"SigmaWorkbook\",\n \"SigmaDataset\",\n \"SigmaDataElement\",\n \"SigmaPage\",\n \"ThoughtspotLiveboard\",\n \"ThoughtspotAnswer\",\n \"ThoughtspotWorksheet\",\n \"MetabaseDashboard\",\n \"MetabaseQuestion\",\n \"MetabaseCollection\",\n \"QuickSightDashboard\",\n \"QuickSightDataset\",\n \"QuickSightAnalysis\",\n \"PresetDashboard\",\n \"PresetChart\",\n \"PresetDataset\",\n \"MicroStrategyDossier\",\n \"MicroStrategyReport\",\n \"ModeReport\",\n \"ModeQuery\",\n \"ModeWorkspace\",\n \"DomoDashboard\",\n \"DomoDataset\",\n \"DomoCard\",\n \"SisenseDashboard\",\n \"SisenseDatamodel\",\n \"RedashDashboard\",\n \"RedashQuery\",\n \"QlikApp\",\n \"QlikDataset\",\n \"QlikSheet\",\n \"QlikSpace\"\n ],\n \"type\": \"string\"\n },\n {\n \"items\": {\n \"enum\": [\n \"Table\",\n \"Column\",\n \"View\",\n \"Database\",\n \"Schema\",\n \"MaterializedView\",\n \"AtlasGlossary\",\n \"AtlasGlossaryTerm\",\n \"AtlasGlossaryCategory\",\n \"Connection\",\n \"Process\",\n \"Query\",\n \"Dashboard\",\n \"Report\",\n \"DataDomain\",\n \"DataProduct\",\n \"PowerBIReport\",\n \"PowerBIDataset\",\n \"PowerBIDashboard\",\n \"PowerBIWorkspace\",\n \"PowerBIDataflow\",\n \"PowerBITable\",\n \"PowerBIMeasure\",\n \"PowerBIColumn\",\n \"PowerBIPage\",\n \"DbtModel\",\n \"DbtTest\",\n \"DbtSource\",\n \"DbtMetric\",\n \"DbtModelColumn\",\n \"TableauDashboard\",\n \"TableauCalculatedField\",\n \"TableauWorkbook\",\n \"TableauDatasource\",\n \"TableauProject\",\n \"TableauWorksheet\",\n \"TableauSite\",\n \"TableauFlow\",\n \"LookerDashboard\",\n \"LookerExplore\",\n \"LookerLook\",\n \"LookerView\",\n \"LookerModel\",\n \"LookerProject\",\n \"LookerQuery\",\n \"LookerFolder\",\n \"LookerTile\",\n \"LookerField\",\n \"SigmaWorkbook\",\n \"SigmaDataset\",\n \"SigmaDataElement\",\n \"SigmaPage\",\n \"ThoughtspotLiveboard\",\n \"ThoughtspotAnswer\",\n \"ThoughtspotWorksheet\",\n \"MetabaseDashboard\",\n \"MetabaseQuestion\",\n \"MetabaseCollection\",\n \"QuickSightDashboard\",\n \"QuickSightDataset\",\n \"QuickSightAnalysis\",\n \"PresetDashboard\",\n \"PresetChart\",\n \"PresetDataset\",\n \"MicroStrategyDossier\",\n \"MicroStrategyReport\",\n \"ModeReport\",\n \"ModeQuery\",\n \"ModeWorkspace\",\n \"DomoDashboard\",\n \"DomoDataset\",\n \"DomoCard\",\n \"SisenseDashboard\",\n \"SisenseDatamodel\",\n \"RedashDashboard\",\n \"RedashQuery\",\n \"QlikApp\",\n \"QlikDataset\",\n \"QlikSheet\",\n \"QlikSpace\"\n ],\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset type(s) to search. Single type or list of types.\"\n }")] = None, - glossary_qualified_name: Annotated[str | None, cyclopts.Parameter(help="Glossary qualifiedName to scope search to terms/categories within that glossary\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Glossary qualifiedName to scope search to terms/categories within that glossary\"\n }")] = None, - include_archived: Annotated[bool, cyclopts.Parameter(help="Include archived/deleted assets")] = False, - limit: Annotated[int, cyclopts.Parameter(help="Max results to return (default 10, max 20)")] = 10, + conditions: Annotated[ + str | None, + cyclopts.Parameter( + help='Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {"__categories": ""} (direct only) or {"__categories": {"operator": "within", "value": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Match filters. Format: {attr: value} or {attr: {operator, value}}. For glossary terms in a category: {\\"__categories\\": \\"\\"} (direct only) or {\\"__categories\\": {\\"operator\\": \\"within\\", \\"value\\": [qn1, qn2, ...]}} (multiple categories including subcategories). DO NOT use relationship-type attributes (e.g. meanings, atlanTags, parentCategory, seeAlso) as condition keys — they are not searchable. Use term_guids to filter by linked glossary terms and tags to filter by Atlan tags.",\n "examples": [\n {\n "name": "customers"\n },\n {\n "name": {\n "operator": "startswith",\n "value": "dim_"\n }\n },\n {\n "certificate_status": "VERIFIED"\n },\n {\n "__typeName": "Table"\n },\n {\n "__categories": ""\n },\n {\n "__categories": {\n "operator": "within",\n "value": [\n "",\n "",\n ""\n ]\n }\n }\n ]\n }' + ), + ] = None, + negative_conditions: Annotated[ + str | None, + cyclopts.Parameter( + help='Exclusion filters (same format as conditions)\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Exclusion filters (same format as conditions)",\n "examples": [\n {\n "certificate_status": "DEPRECATED"\n },\n {\n "name": {\n "operator": "contains",\n "value": "test"\n }\n }\n ]\n }' + ), + ] = None, + some_conditions: Annotated[ + str | None, + cyclopts.Parameter( + help='OR-style filters requiring min_somes matches\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "OR-style filters requiring min_somes matches",\n "examples": [\n {\n "owner_groups": "admins",\n "owner_users": "user1"\n }\n ]\n }' + ), + ] = None, + min_somes: Annotated[ + int, cyclopts.Parameter(help="Minimum some_conditions to match") + ] = 1, + include_attributes: Annotated[ + str | None, + cyclopts.Parameter( + help='Attributes to return (e.g., owner_users, columns, readme). For category hierarchy traversal include parentCategory and qualifiedName.\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "type": "string"\n },\n "type": "array"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Attributes to return (e.g., owner_users, columns, readme). For category hierarchy traversal include parentCategory and qualifiedName."\n }' + ), + ] = None, + asset_type: Annotated[ + str | None, + cyclopts.Parameter( + help='Asset type(s) to search. Single type or list of types.\\nJSON Schema: {\n "anyOf": [\n {\n "enum": [\n "Table",\n "Column",\n "View",\n "Database",\n "Schema",\n "MaterializedView",\n "AtlasGlossary",\n "AtlasGlossaryTerm",\n "AtlasGlossaryCategory",\n "Connection",\n "Process",\n "Query",\n "Dashboard",\n "Report",\n "DataDomain",\n "DataProduct",\n "PowerBIReport",\n "PowerBIDataset",\n "PowerBIDashboard",\n "PowerBIWorkspace",\n "PowerBIDataflow",\n "PowerBITable",\n "PowerBIMeasure",\n "PowerBIColumn",\n "PowerBIPage",\n "DbtModel",\n "DbtTest",\n "DbtSource",\n "DbtMetric",\n "DbtModelColumn",\n "TableauDashboard",\n "TableauCalculatedField",\n "TableauWorkbook",\n "TableauDatasource",\n "TableauProject",\n "TableauWorksheet",\n "TableauSite",\n "TableauFlow",\n "LookerDashboard",\n "LookerExplore",\n "LookerLook",\n "LookerView",\n "LookerModel",\n "LookerProject",\n "LookerQuery",\n "LookerFolder",\n "LookerTile",\n "LookerField",\n "SigmaWorkbook",\n "SigmaDataset",\n "SigmaDataElement",\n "SigmaPage",\n "ThoughtspotLiveboard",\n "ThoughtspotAnswer",\n "ThoughtspotWorksheet",\n "MetabaseDashboard",\n "MetabaseQuestion",\n "MetabaseCollection",\n "QuickSightDashboard",\n "QuickSightDataset",\n "QuickSightAnalysis",\n "PresetDashboard",\n "PresetChart",\n "PresetDataset",\n "MicroStrategyDossier",\n "MicroStrategyReport",\n "ModeReport",\n "ModeQuery",\n "ModeWorkspace",\n "DomoDashboard",\n "DomoDataset",\n "DomoCard",\n "SisenseDashboard",\n "SisenseDatamodel",\n "RedashDashboard",\n "RedashQuery",\n "QlikApp",\n "QlikDataset",\n "QlikSheet",\n "QlikSpace"\n ],\n "type": "string"\n },\n {\n "items": {\n "enum": [\n "Table",\n "Column",\n "View",\n "Database",\n "Schema",\n "MaterializedView",\n "AtlasGlossary",\n "AtlasGlossaryTerm",\n "AtlasGlossaryCategory",\n "Connection",\n "Process",\n "Query",\n "Dashboard",\n "Report",\n "DataDomain",\n "DataProduct",\n "PowerBIReport",\n "PowerBIDataset",\n "PowerBIDashboard",\n "PowerBIWorkspace",\n "PowerBIDataflow",\n "PowerBITable",\n "PowerBIMeasure",\n "PowerBIColumn",\n "PowerBIPage",\n "DbtModel",\n "DbtTest",\n "DbtSource",\n "DbtMetric",\n "DbtModelColumn",\n "TableauDashboard",\n "TableauCalculatedField",\n "TableauWorkbook",\n "TableauDatasource",\n "TableauProject",\n "TableauWorksheet",\n "TableauSite",\n "TableauFlow",\n "LookerDashboard",\n "LookerExplore",\n "LookerLook",\n "LookerView",\n "LookerModel",\n "LookerProject",\n "LookerQuery",\n "LookerFolder",\n "LookerTile",\n "LookerField",\n "SigmaWorkbook",\n "SigmaDataset",\n "SigmaDataElement",\n "SigmaPage",\n "ThoughtspotLiveboard",\n "ThoughtspotAnswer",\n "ThoughtspotWorksheet",\n "MetabaseDashboard",\n "MetabaseQuestion",\n "MetabaseCollection",\n "QuickSightDashboard",\n "QuickSightDataset",\n "QuickSightAnalysis",\n "PresetDashboard",\n "PresetChart",\n "PresetDataset",\n "MicroStrategyDossier",\n "MicroStrategyReport",\n "ModeReport",\n "ModeQuery",\n "ModeWorkspace",\n "DomoDashboard",\n "DomoDataset",\n "DomoCard",\n "SisenseDashboard",\n "SisenseDatamodel",\n "RedashDashboard",\n "RedashQuery",\n "QlikApp",\n "QlikDataset",\n "QlikSheet",\n "QlikSpace"\n ],\n "type": "string"\n },\n "type": "array"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Asset type(s) to search. Single type or list of types."\n }' + ), + ] = None, + glossary_qualified_name: Annotated[ + str | None, + cyclopts.Parameter( + help='Glossary qualifiedName to scope search to terms/categories within that glossary\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Glossary qualifiedName to scope search to terms/categories within that glossary"\n }' + ), + ] = None, + include_archived: Annotated[ + bool, cyclopts.Parameter(help="Include archived/deleted assets") + ] = False, + limit: Annotated[ + int, cyclopts.Parameter(help="Max results to return (default 10, max 20)") + ] = 10, offset: Annotated[int, cyclopts.Parameter(help="Pagination offset")] = 0, - sort_by: Annotated[str | None, cyclopts.Parameter(help="Single field to sort by\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Single field to sort by\"\n }")] = None, - sort_order: Annotated[str, cyclopts.Parameter(help="Sort order")] = 'ASC', - sort: Annotated[str | None, cyclopts.Parameter(help="Multi-field sort\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Multi-field sort\",\n \"examples\": [\n [\n {\n \"field\": \"name\",\n \"order\": \"ASC\"\n }\n ],\n [\n {\n \"field\": \"createTime\",\n \"order\": \"DESC\"\n },\n {\n \"field\": \"name\",\n \"order\": \"ASC\"\n }\n ]\n ]\n }")] = None, - connection_qualified_name: Annotated[str | None, cyclopts.Parameter(help="Filter by connection (e.g., default/snowflake/123)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by connection (e.g., default/snowflake/123)\"\n }")] = None, - tags: Annotated[str | None, cyclopts.Parameter(help="Filter by Atlan tag names\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by Atlan tag names\"\n }")] = None, - directly_tagged: Annotated[bool, cyclopts.Parameter(help="Only directly tagged (not inherited)")] = True, - domain_guids: Annotated[str | None, cyclopts.Parameter(help="Filter by domain GUIDs. For DataProduct/DataDomain asset types, this recursively includes all sub-domains — e.g. passing a parent domain GUID returns products under that domain AND all its sub-domains at any depth.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by domain GUIDs. For DataProduct/DataDomain asset types, this recursively includes all sub-domains — e.g. passing a parent domain GUID returns products under that domain AND all its sub-domains at any depth.\"\n }")] = None, - date_range: Annotated[str | None, cyclopts.Parameter(help="Date filters\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Date filters\",\n \"examples\": [\n {\n \"createTime\": {\n \"gte\": 1704067200000\n }\n },\n {\n \"updateTime\": {\n \"gte\": 1704067200000,\n \"lte\": 1706745600000\n }\n }\n ]\n }")] = None, - guids: Annotated[str | None, cyclopts.Parameter(help="Filter by specific asset GUIDs\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by specific asset GUIDs\"\n }")] = None, - term_guids: Annotated[str | None, cyclopts.Parameter(help="Filter by assigned glossary term GUIDs\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by assigned glossary term GUIDs\"\n }")] = None, - aggregations: Annotated[str | None, cyclopts.Parameter(help="Aggregations to compute. Use __meanings aggregation to find which glossary terms are linked to assets.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Aggregations to compute. Use __meanings aggregation to find which glossary terms are linked to assets.\",\n \"examples\": [\n {\n \"by_type\": {\n \"field\": \"__typeName\",\n \"size\": 10,\n \"type\": \"terms\"\n }\n },\n {\n \"by_owner\": {\n \"field\": \"ownerUsers\",\n \"size\": 20,\n \"type\": \"terms\"\n }\n },\n {\n \"linked_terms\": {\n \"field\": \"__meanings\",\n \"size\": 500\n }\n }\n ]\n }")] = None, - count_only: Annotated[bool, cyclopts.Parameter(help="Return only count, no results")] = False, - scroll: Annotated[bool, cyclopts.Parameter(help="Enable scroll mode for >10k results")] = False, - include_readme: Annotated[bool, cyclopts.Parameter(help="Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency.")] = False, - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + sort_by: Annotated[ + str | None, + cyclopts.Parameter( + help='Single field to sort by\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Single field to sort by"\n }' + ), + ] = None, + sort_order: Annotated[str, cyclopts.Parameter(help="Sort order")] = "ASC", + sort: Annotated[ + str | None, + cyclopts.Parameter( + help='Multi-field sort\\nJSON Schema: {\n "anyOf": [\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Multi-field sort",\n "examples": [\n [\n {\n "field": "name",\n "order": "ASC"\n }\n ],\n [\n {\n "field": "createTime",\n "order": "DESC"\n },\n {\n "field": "name",\n "order": "ASC"\n }\n ]\n ]\n }' + ), + ] = None, + connection_qualified_name: Annotated[ + str | None, + cyclopts.Parameter( + help='Filter by connection (e.g., default/snowflake/123)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Filter by connection (e.g., default/snowflake/123)"\n }' + ), + ] = None, + tags: Annotated[ + str | None, + cyclopts.Parameter( + help='Filter by Atlan tag names\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "type": "string"\n },\n "type": "array"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Filter by Atlan tag names"\n }' + ), + ] = None, + directly_tagged: Annotated[ + bool, cyclopts.Parameter(help="Only directly tagged (not inherited)") + ] = True, + domain_guids: Annotated[ + str | None, + cyclopts.Parameter( + help='Filter by domain GUIDs. For DataProduct/DataDomain asset types, this recursively includes all sub-domains — e.g. passing a parent domain GUID returns products under that domain AND all its sub-domains at any depth.\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "type": "string"\n },\n "type": "array"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Filter by domain GUIDs. For DataProduct/DataDomain asset types, this recursively includes all sub-domains — e.g. passing a parent domain GUID returns products under that domain AND all its sub-domains at any depth."\n }' + ), + ] = None, + date_range: Annotated[ + str | None, + cyclopts.Parameter( + help='Date filters\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Date filters",\n "examples": [\n {\n "createTime": {\n "gte": 1704067200000\n }\n },\n {\n "updateTime": {\n "gte": 1704067200000,\n "lte": 1706745600000\n }\n }\n ]\n }' + ), + ] = None, + guids: Annotated[ + str | None, + cyclopts.Parameter( + help='Filter by specific asset GUIDs\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "type": "string"\n },\n "type": "array"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Filter by specific asset GUIDs"\n }' + ), + ] = None, + term_guids: Annotated[ + str | None, + cyclopts.Parameter( + help='Filter by assigned glossary term GUIDs\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "type": "string"\n },\n "type": "array"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Filter by assigned glossary term GUIDs"\n }' + ), + ] = None, + aggregations: Annotated[ + str | None, + cyclopts.Parameter( + help='Aggregations to compute. Use __meanings aggregation to find which glossary terms are linked to assets.\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Aggregations to compute. Use __meanings aggregation to find which glossary terms are linked to assets.",\n "examples": [\n {\n "by_type": {\n "field": "__typeName",\n "size": 10,\n "type": "terms"\n }\n },\n {\n "by_owner": {\n "field": "ownerUsers",\n "size": 20,\n "type": "terms"\n }\n },\n {\n "linked_terms": {\n "field": "__meanings",\n "size": 500\n }\n }\n ]\n }' + ), + ] = None, + count_only: Annotated[ + bool, cyclopts.Parameter(help="Return only count, no results") + ] = False, + scroll: Annotated[ + bool, cyclopts.Parameter(help="Enable scroll mode for >10k results") + ] = False, + include_readme: Annotated[ + bool, + cyclopts.Parameter( + help="Set true to fetch and return README content for each asset. Only enable when the user explicitly asks for README/documentation content — adds latency." + ), + ] = False, + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''BACKUP search tool — only use when the primary search tools (start_deep_query_tool or semantic_search_tool) cannot express what you need. Provides structured asset search with exact filters, conditions, aggregations, sorting, and pagination. Use this ONLY for precise attribute filters (e.g. certificateStatus, connectorName), term_guids, tags, domain_guids, count_only, or aggregations that the primary tools don\'t support. - -LINKED/UNLINKED TERMS — MANDATORY 2-CALL FLOW: -When the user asks about linked, unlinked, orphan, or unused glossary terms you MUST make TWO separate calls: -Call 1: Get terms — search_assets(asset_type="AtlasGlossaryTerm", glossary_qualified_name="", include_attributes=["qualifiedName","name"], limit=100) -Call 2: Get linked term QNs — search_assets(aggregations={"linked_terms": {"field": "__meanings", "size": 500}}, limit=1) — NO asset_type, NO glossary filter. This aggregates __meanings across ALL assets in the catalog. -Then DIFF: terms from Call 1 whose qualifiedName appears in Call 2\'s aggregation buckets are LINKED. Terms absent are UNLINKED. -IMPORTANT: Call 2 must NOT have asset_type or glossary_qualified_name filters — __meanings lives on regular assets (Tables, Columns), not on terms. Scoping Call 2 to terms returns empty buckets. - -GLOSSARY TERM WORKFLOWS: -- Filter terms by category: conditions={"__categories": ""}. NOTE: __categories only stores the DIRECT parent category. To include subcategories, first fetch all categories in the glossary (asset_type="AtlasGlossaryCategory", include_attributes=["qualifiedName","parentCategory"]), walk the parentCategory tree to find all descendants, then use conditions={"__categories": {"operator": "within", "value": [allDescendantQNs]}}. -NOT for users/groups — use resolve_metadata_tool for those.''' + """BACKUP search tool — only use when the primary search tools (start_deep_query_tool or semantic_search_tool) cannot express what you need. Provides structured asset search with exact filters, conditions, aggregations, sorting, and pagination. Use this ONLY for precise attribute filters (e.g. certificateStatus, connectorName), term_guids, tags, domain_guids, count_only, or aggregations that the primary tools don\'t support. + + LINKED/UNLINKED TERMS — MANDATORY 2-CALL FLOW: + When the user asks about linked, unlinked, orphan, or unused glossary terms you MUST make TWO separate calls: + Call 1: Get terms — search_assets(asset_type="AtlasGlossaryTerm", glossary_qualified_name="", include_attributes=["qualifiedName","name"], limit=100) + Call 2: Get linked term QNs — search_assets(aggregations={"linked_terms": {"field": "__meanings", "size": 500}}, limit=1) — NO asset_type, NO glossary filter. This aggregates __meanings across ALL assets in the catalog. + Then DIFF: terms from Call 1 whose qualifiedName appears in Call 2\'s aggregation buckets are LINKED. Terms absent are UNLINKED. + IMPORTANT: Call 2 must NOT have asset_type or glossary_qualified_name filters — __meanings lives on regular assets (Tables, Columns), not on terms. Scoping Call 2 to terms returns empty buckets. + + GLOSSARY TERM WORKFLOWS: + - Filter terms by category: conditions={"__categories": ""}. NOTE: __categories only stores the DIRECT parent category. To include subcategories, first fetch all categories in the glossary (asset_type="AtlasGlossaryCategory", include_attributes=["qualifiedName","parentCategory"]), walk the parentCategory tree to find all descendants, then use conditions={"__categories": {"operator": "within", "value": [allDescendantQNs]}}. + NOT for users/groups — use resolve_metadata_tool for those.""" conditions_parsed = _maybe_json(conditions) negative_conditions_parsed = _maybe_json(negative_conditions) some_conditions_parsed = _maybe_json(some_conditions) @@ -715,379 +917,1005 @@ async def search_assets_tool( term_guids_parsed = _maybe_json(term_guids) aggregations_parsed = _maybe_json(aggregations) - await _call_tool('search_assets_tool', {'conditions': conditions_parsed, 'negative_conditions': negative_conditions_parsed, 'some_conditions': some_conditions_parsed, 'min_somes': min_somes, 'include_attributes': include_attributes_parsed, 'asset_type': asset_type_parsed, 'glossary_qualified_name': glossary_qualified_name, 'include_archived': include_archived, 'limit': limit, 'offset': offset, 'sort_by': sort_by, 'sort_order': sort_order, 'sort': sort_parsed, 'connection_qualified_name': connection_qualified_name, 'tags': tags_parsed, 'directly_tagged': directly_tagged, 'domain_guids': domain_guids_parsed, 'date_range': date_range_parsed, 'guids': guids_parsed, 'term_guids': term_guids_parsed, 'aggregations': aggregations_parsed, 'count_only': count_only, 'scroll': scroll, 'include_readme': include_readme, 'user_query': user_query}) - - -@app.command(name='traverse_lineage_tool') + await _call_tool( + "search_assets_tool", + { + "conditions": conditions_parsed, + "negative_conditions": negative_conditions_parsed, + "some_conditions": some_conditions_parsed, + "min_somes": min_somes, + "include_attributes": include_attributes_parsed, + "asset_type": asset_type_parsed, + "glossary_qualified_name": glossary_qualified_name, + "include_archived": include_archived, + "limit": limit, + "offset": offset, + "sort_by": sort_by, + "sort_order": sort_order, + "sort": sort_parsed, + "connection_qualified_name": connection_qualified_name, + "tags": tags_parsed, + "directly_tagged": directly_tagged, + "domain_guids": domain_guids_parsed, + "date_range": date_range_parsed, + "guids": guids_parsed, + "term_guids": term_guids_parsed, + "aggregations": aggregations_parsed, + "count_only": count_only, + "scroll": scroll, + "include_readme": include_readme, + "user_query": user_query, + }, + ) + + +@app.command(name="traverse_lineage_tool") async def traverse_lineage_tool( *, guid: Annotated[str, cyclopts.Parameter(help="GUID of the starting asset")], direction: Annotated[str, cyclopts.Parameter(help="Lineage traversal direction")], - depth: Annotated[int, cyclopts.Parameter(help="Maximum depth to traverse")] = 1000000, - size: Annotated[int, cyclopts.Parameter(help="Maximum number of data assets to show in the lineage graph. The widget supports progressive loading — users can click expand on leaf nodes to load more. Keep this small (5-15). Max 20.")] = 10, - immediate_neighbors: Annotated[bool, cyclopts.Parameter(help="Only return immediate neighbors (one hop)")] = True, - offset: Annotated[int, cyclopts.Parameter(help="Pagination offset for relations (default 0)")] = 0, - include_attributes: Annotated[str | None, cyclopts.Parameter(help="Additional attributes to include\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Additional attributes to include\"\n }")] = None, - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + depth: Annotated[ + int, cyclopts.Parameter(help="Maximum depth to traverse") + ] = 1000000, + size: Annotated[ + int, + cyclopts.Parameter( + help="Maximum number of data assets to show in the lineage graph. The widget supports progressive loading — users can click expand on leaf nodes to load more. Keep this small (5-15). Max 20." + ), + ] = 10, + immediate_neighbors: Annotated[ + bool, cyclopts.Parameter(help="Only return immediate neighbors (one hop)") + ] = True, + offset: Annotated[ + int, cyclopts.Parameter(help="Pagination offset for relations (default 0)") + ] = 0, + include_attributes: Annotated[ + str | None, + cyclopts.Parameter( + help='Additional attributes to include\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Additional attributes to include"\n }' + ), + ] = None, + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Traverse upstream or downstream lineage from an asset. - -Returns a widget-ready graph with data assets connected by lineage edges. -Process connector nodes are bridged in the graph (A -> B instead of -A -> Process -> B); their entity data is exposed in the ``process_map`` -field and each relation carries ``via_process_guids`` identifying which -ETL/transformation processes produced the connection. The lineage widget -supports progressive loading — leaf nodes show an expand button that -fetches deeper lineage on demand. Keep size small (5-15) for responsive -results; do NOT request all lineage at once.''' - - await _call_tool('traverse_lineage_tool', {'guid': guid, 'direction': direction, 'depth': depth, 'size': size, 'immediate_neighbors': immediate_neighbors, 'offset': offset, 'include_attributes': include_attributes, 'user_query': user_query}) - - -@app.command(name='query_assets_tool') + """Traverse upstream or downstream lineage from an asset. + + Returns a widget-ready graph with data assets connected by lineage edges. + Process connector nodes are bridged in the graph (A -> B instead of + A -> Process -> B); their entity data is exposed in the ``process_map`` + field and each relation carries ``via_process_guids`` identifying which + ETL/transformation processes produced the connection. The lineage widget + supports progressive loading — leaf nodes show an expand button that + fetches deeper lineage on demand. Keep size small (5-15) for responsive + results; do NOT request all lineage at once.""" + + await _call_tool( + "traverse_lineage_tool", + { + "guid": guid, + "direction": direction, + "depth": depth, + "size": size, + "immediate_neighbors": immediate_neighbors, + "offset": offset, + "include_attributes": include_attributes, + "user_query": user_query, + }, + ) + + +@app.command(name="query_assets_tool") async def query_assets_tool( *, - sql: Annotated[str, cyclopts.Parameter(help="SQL query to execute against the connection")], - connection_qualified_name: Annotated[str, cyclopts.Parameter(help="Qualified name of the connection to query")], - default_schema: Annotated[str | None, cyclopts.Parameter(help="Default schema for unqualified table references\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Default schema for unqualified table references\"\n }")] = None, - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + sql: Annotated[ + str, cyclopts.Parameter(help="SQL query to execute against the connection") + ], + connection_qualified_name: Annotated[ + str, cyclopts.Parameter(help="Qualified name of the connection to query") + ], + default_schema: Annotated[ + str | None, + cyclopts.Parameter( + help='Default schema for unqualified table references\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Default schema for unqualified table references"\n }' + ), + ] = None, + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Execute SQL queries against Atlan connections to preview data.''' + """Execute SQL queries against Atlan connections to preview data.""" - await _call_tool('query_assets_tool', {'sql': sql, 'connection_qualified_name': connection_qualified_name, 'default_schema': default_schema, 'user_query': user_query}) + await _call_tool( + "query_assets_tool", + { + "sql": sql, + "connection_qualified_name": connection_qualified_name, + "default_schema": default_schema, + "user_query": user_query, + }, + ) -@app.command(name='resolve_metadata_tool') +@app.command(name="resolve_metadata_tool") async def resolve_metadata_tool( *, - namespace_type: Annotated[str, cyclopts.Parameter(help="Metadata namespace to search. Use 'data_domain_and_product' for BOTH data domains AND data products.")], - query: Annotated[str, cyclopts.Parameter(help="Search query - name, description, or natural language")], - limit: Annotated[int, cyclopts.Parameter(help="Max results (default 10, max 20)")] = 10, - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + namespace_type: Annotated[ + str, + cyclopts.Parameter( + help="Metadata namespace to search. Use 'data_domain_and_product' for BOTH data domains AND data products." + ), + ], + query: Annotated[ + str, + cyclopts.Parameter( + help="Search query - name, description, or natural language" + ), + ], + limit: Annotated[ + int, cyclopts.Parameter(help="Max results (default 10, max 20)") + ] = 10, + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Use this to search for users and groups, and to get exact usernames and group names before making updates. Also use for extra discovery on classifications, business_metadata, glossary, and data_domain_and_product when semantic_search doesn\'t return needed results, or before write operations to confirm exact names and GUIDs.''' + """Use this to search for users and groups, and to get exact usernames and group names before making updates. Also use for extra discovery on classifications, business_metadata, glossary, and data_domain_and_product when semantic_search doesn\'t return needed results, or before write operations to confirm exact names and GUIDs.""" - await _call_tool('resolve_metadata_tool', {'namespace_type': namespace_type, 'query': query, 'limit': limit, 'user_query': user_query}) + await _call_tool( + "resolve_metadata_tool", + { + "namespace_type": namespace_type, + "query": query, + "limit": limit, + "user_query": user_query, + }, + ) -@app.command(name='search_atlan_docs_tool') +@app.command(name="search_atlan_docs_tool") async def search_atlan_docs_tool( *, - query: Annotated[str, cyclopts.Parameter(help="Question about Atlan features, how-tos, or configuration. E.g. 'How do I connect Snowflake to Atlan?' or 'How do I create a data product?'")], - top_k: Annotated[int, cyclopts.Parameter(help="Number of documentation sources to retrieve (1-10)")] = 5, - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + query: Annotated[ + str, + cyclopts.Parameter( + help="Question about Atlan features, how-tos, or configuration. E.g. 'How do I connect Snowflake to Atlan?' or 'How do I create a data product?'" + ), + ], + top_k: Annotated[ + int, + cyclopts.Parameter(help="Number of documentation sources to retrieve (1-10)"), + ] = 5, + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Search Atlan\'s customer-facing documentation and return an LLM-generated answer with source citations. Use for how-to questions about Atlan features — not for searching data assets (use semantic_search_tool for that).''' + """Search Atlan\'s customer-facing documentation and return an LLM-generated answer with source citations. Use for how-to questions about Atlan features — not for searching data assets (use semantic_search_tool for that).""" - await _call_tool('search_atlan_docs_tool', {'query': query, 'top_k': top_k, 'user_query': user_query}) + await _call_tool( + "search_atlan_docs_tool", + {"query": query, "top_k": top_k, "user_query": user_query}, + ) -@app.command(name='get_groups_tool') +@app.command(name="get_groups_tool") async def get_groups_tool( *, - name_filter: Annotated[str | None, cyclopts.Parameter(help="Filter by name (partial match)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Filter by name (partial match)\"\n }")] = None, - group_id: Annotated[str | None, cyclopts.Parameter(help="Get specific group by ID (GUID)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Get specific group by ID (GUID)\"\n }")] = None, - include_members: Annotated[bool, cyclopts.Parameter(help="Include group members in response")] = False, + name_filter: Annotated[ + str | None, + cyclopts.Parameter( + help='Filter by name (partial match)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Filter by name (partial match)"\n }' + ), + ] = None, + group_id: Annotated[ + str | None, + cyclopts.Parameter( + help='Get specific group by ID (GUID)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Get specific group by ID (GUID)"\n }' + ), + ] = None, + include_members: Annotated[ + bool, cyclopts.Parameter(help="Include group members in response") + ] = False, limit: Annotated[int, cyclopts.Parameter(help="Maximum results (1-250)")] = 50, offset: Annotated[int, cyclopts.Parameter(help="Pagination offset")] = 0, - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Get workspace groups and their members. Use include_members=True to list users in a group.''' - - await _call_tool('get_groups_tool', {'name_filter': name_filter, 'group_id': group_id, 'include_members': include_members, 'limit': limit, 'offset': offset, 'user_query': user_query}) - - -@app.command(name='get_asset_tool') + """Get workspace groups and their members. Use include_members=True to list users in a group.""" + + await _call_tool( + "get_groups_tool", + { + "name_filter": name_filter, + "group_id": group_id, + "include_members": include_members, + "limit": limit, + "offset": offset, + "user_query": user_query, + }, + ) + + +@app.command(name="get_asset_tool") async def get_asset_tool( *, - guid: Annotated[str | None, cyclopts.Parameter(help="Asset GUID to retrieve\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset GUID to retrieve\"\n }")] = None, - qualified_name: Annotated[str | None, cyclopts.Parameter(help="Asset qualified name to retrieve (e.g. default/snowflake/1234/DB/SCHEMA/TABLE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset qualified name to retrieve (e.g. default/snowflake/1234/DB/SCHEMA/TABLE)\"\n }")] = None, - asset_type: Annotated[str | None, cyclopts.Parameter(help="Asset type (required when using qualified_name). e.g. Table, Column, View, Database, Schema\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset type (required when using qualified_name). e.g. Table, Column, View, Database, Schema\"\n }")] = None, - include_attributes: Annotated[str | None, cyclopts.Parameter(help="Additional attributes to include\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Additional attributes to include\"\n }")] = None, - include_dq_checks: Annotated[bool, cyclopts.Parameter(help="Set true to include linked data quality checks (Soda, Anomalo, Monte Carlo, Atlan native DQ rules) for this asset")] = False, - include_readme: Annotated[bool, cyclopts.Parameter(help="Set true to fetch and return README content for the asset. Only enable when the user explicitly asks for README/documentation content — adds latency.")] = False, - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + guid: Annotated[ + str | None, + cyclopts.Parameter( + help='Asset GUID to retrieve\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Asset GUID to retrieve"\n }' + ), + ] = None, + qualified_name: Annotated[ + str | None, + cyclopts.Parameter( + help='Asset qualified name to retrieve (e.g. default/snowflake/1234/DB/SCHEMA/TABLE)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Asset qualified name to retrieve (e.g. default/snowflake/1234/DB/SCHEMA/TABLE)"\n }' + ), + ] = None, + asset_type: Annotated[ + str | None, + cyclopts.Parameter( + help='Asset type (required when using qualified_name). e.g. Table, Column, View, Database, Schema\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Asset type (required when using qualified_name). e.g. Table, Column, View, Database, Schema"\n }' + ), + ] = None, + include_attributes: Annotated[ + str | None, + cyclopts.Parameter( + help='Additional attributes to include\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Additional attributes to include"\n }' + ), + ] = None, + include_dq_checks: Annotated[ + bool, + cyclopts.Parameter( + help="Set true to include linked data quality checks (Soda, Anomalo, Monte Carlo, Atlan native DQ rules) for this asset" + ), + ] = False, + include_readme: Annotated[ + bool, + cyclopts.Parameter( + help="Set true to fetch and return README content for the asset. Only enable when the user explicitly asks for README/documentation content — adds latency." + ), + ] = False, + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Get detailed information about a single asset by its GUID or qualified name.''' - - await _call_tool('get_asset_tool', {'guid': guid, 'qualified_name': qualified_name, 'asset_type': asset_type, 'include_attributes': include_attributes, 'include_dq_checks': include_dq_checks, 'include_readme': include_readme, 'user_query': user_query}) - - -@app.command(name='update_assets_tool') + """Get detailed information about a single asset by its GUID or qualified name.""" + + await _call_tool( + "get_asset_tool", + { + "guid": guid, + "qualified_name": qualified_name, + "asset_type": asset_type, + "include_attributes": include_attributes, + "include_dq_checks": include_dq_checks, + "include_readme": include_readme, + "user_query": user_query, + }, + ) + + +@app.command(name="update_assets_tool") async def update_assets_tool( *, - updates: Annotated[str, cyclopts.Parameter(help="Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly 'append' (link terms), 'remove' (unlink terms), or 'replace' (overwrite all). Do NOT use 'add' or 'delete' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is 'append', 'replace', or 'remove' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly 'append' (link terms), 'remove' (unlink terms), or 'replace' (overwrite all). Do NOT use 'add' or 'delete' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is 'append', 'replace', or 'remove' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.\",\n \"examples\": [\n {\n \"certificate_status\": \"VERIFIED\",\n \"guid\": \"abc-123\",\n \"name\": \"customers\",\n \"qualified_name\": \"default/snowflake/db/schema/customers\",\n \"type_name\": \"Table\",\n \"user_description\": \"Customer master data\"\n },\n [\n {\n \"guid\": \"abc-123\",\n \"name\": \"t1\",\n \"owner_users\": [\n \"user1\",\n \"user2\"\n ],\n \"qualified_name\": \"qn1\",\n \"type_name\": \"Table\"\n },\n {\n \"guid\": \"def-456\",\n \"name\": \"t2\",\n \"qualified_name\": \"qn2\",\n \"term\": {\n \"operation\": \"append\",\n \"term_guids\": [\n \"guid1\"\n ]\n },\n \"type_name\": \"Table\"\n }\n ],\n [\n {\n \"certificate_status\": \"DRAFT\",\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-456\",\n \"name\": \"Churn Rate\",\n \"qualified_name\": \"abc@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\"\n },\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-789\",\n \"name\": \"Revenue\",\n \"qualified_name\": \"def@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\",\n \"user_description\": \"Total income\"\n }\n ],\n {\n \"category_guids\": [\n \"cat-guid-1\",\n \"cat-guid-2\"\n ],\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-101\",\n \"name\": \"Net Revenue\",\n \"qualified_name\": \"ghi@glossary-qn\",\n \"type_name\": \"AtlasGlossaryTerm\"\n },\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"guid\": \"term-202\",\n \"name\": \"Revenue\",\n \"qualified_name\": \"jkl@glossary-qn\",\n \"term_relations\": {\n \"antonyms\": {\n \"guids\": [\n \"guid-of-cost\"\n ],\n \"op\": \"append\"\n },\n \"synonyms\": {\n \"guids\": [\n \"guid-of-income\",\n \"guid-of-earnings\"\n ],\n \"op\": \"append\"\n }\n },\n \"type_name\": \"AtlasGlossaryTerm\"\n }\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + updates: Annotated[ + str, + cyclopts.Parameter( + help='Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly \'append\' (link terms), \'remove\' (unlink terms), or \'replace\' (overwrite all). Do NOT use \'add\' or \'delete\' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is \'append\', \'replace\', or \'remove\' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "Asset update(s). Each update is a self-contained dict with asset identity (guid, name, qualified_name, type_name) plus the fields to change: user_description, certificate_status, readme, term, owner_users, owner_groups, category_guids, term_relations. Multiple attributes can be updated per asset in one call. For owner_users/owner_groups: REPLACES the full list — include existing owners to keep them. Use exact usernames from resolve_metadata. For category_guids: list of category GUIDs to assign an AtlasGlossaryTerm to (replaces existing categories). For AtlasGlossaryTerm or AtlasGlossaryCategory: also include glossary_guid (the parent glossary GUID, available as glossaryGuid in search results). For term: operation must be exactly \'append\' (link terms), \'remove\' (unlink terms), or \'replace\' (overwrite all). Do NOT use \'add\' or \'delete\' — these are invalid and will fail. For term_relations (AtlasGlossaryTerm only): link one term to another using a relation type. Supported types: synonyms, antonyms, see_also (Related to), preferred_terms (Recommended), preferred_to_terms, translated_terms (Translates to), valid_values, valid_values_for, classifies, is_a (Classified by), replaced_by, replacement_terms. Each relation type takes {op, guids} where op is \'append\', \'replace\', or \'remove\' and guids is a list of target term GUIDs. Use search_assets_tool to find GUIDs of both source and target terms before calling this tool.",\n "examples": [\n {\n "certificate_status": "VERIFIED",\n "guid": "abc-123",\n "name": "customers",\n "qualified_name": "default/snowflake/db/schema/customers",\n "type_name": "Table",\n "user_description": "Customer master data"\n },\n [\n {\n "guid": "abc-123",\n "name": "t1",\n "owner_users": [\n "user1",\n "user2"\n ],\n "qualified_name": "qn1",\n "type_name": "Table"\n },\n {\n "guid": "def-456",\n "name": "t2",\n "qualified_name": "qn2",\n "term": {\n "operation": "append",\n "term_guids": [\n "guid1"\n ]\n },\n "type_name": "Table"\n }\n ],\n [\n {\n "certificate_status": "DRAFT",\n "glossary_guid": "glossary-guid-123",\n "guid": "term-456",\n "name": "Churn Rate",\n "qualified_name": "abc@glossary-qn",\n "type_name": "AtlasGlossaryTerm"\n },\n {\n "glossary_guid": "glossary-guid-123",\n "guid": "term-789",\n "name": "Revenue",\n "qualified_name": "def@glossary-qn",\n "type_name": "AtlasGlossaryTerm",\n "user_description": "Total income"\n }\n ],\n {\n "category_guids": [\n "cat-guid-1",\n "cat-guid-2"\n ],\n "glossary_guid": "glossary-guid-123",\n "guid": "term-101",\n "name": "Net Revenue",\n "qualified_name": "ghi@glossary-qn",\n "type_name": "AtlasGlossaryTerm"\n },\n {\n "glossary_guid": "glossary-guid-123",\n "guid": "term-202",\n "name": "Revenue",\n "qualified_name": "jkl@glossary-qn",\n "term_relations": {\n "antonyms": {\n "guids": [\n "guid-of-cost"\n ],\n "op": "append"\n },\n "synonyms": {\n "guids": [\n "guid-of-income",\n "guid-of-earnings"\n ],\n "op": "append"\n }\n },\n "type_name": "AtlasGlossaryTerm"\n }\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Update attributes on one or more assets. Each item specifies the asset identity + fields to change. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Update attributes on one or more assets. Each item specifies the asset identity + fields to change. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" updates_parsed = _maybe_json(updates) - await _call_tool('update_assets_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "update_assets_tool", + {"updates": updates_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='create_glossaries') +@app.command(name="create_glossaries") async def create_glossaries( *, - glossaries: Annotated[str, cyclopts.Parameter(help="Glossary definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Glossary definitions\",\n \"examples\": [\n {\n \"name\": \"Business Glossary\",\n \"user_description\": \"Company-wide terms\"\n },\n [\n {\n \"name\": \"Glossary A\"\n },\n {\n \"certificate_status\": \"VERIFIED\",\n \"name\": \"Glossary B\"\n }\n ]\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + glossaries: Annotated[ + str, + cyclopts.Parameter( + help='Glossary definitions\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "Glossary definitions",\n "examples": [\n {\n "name": "Business Glossary",\n "user_description": "Company-wide terms"\n },\n [\n {\n "name": "Glossary A"\n },\n {\n "certificate_status": "VERIFIED",\n "name": "Glossary B"\n }\n ]\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Create one or more glossaries in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Create one or more glossaries in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" glossaries_parsed = _maybe_json(glossaries) - await _call_tool('create_glossaries', {'glossaries': glossaries_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "create_glossaries", + {"glossaries": glossaries_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='create_glossary_terms') +@app.command(name="create_glossary_terms") async def create_glossary_terms( *, - terms: Annotated[str, cyclopts.Parameter(help="Term definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Term definitions\",\n \"examples\": [\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"name\": \"Revenue\",\n \"user_description\": \"Total income\"\n },\n [\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Term A\"\n },\n {\n \"category_guids\": [\n \"cat-1\"\n ],\n \"glossary_guid\": \"g-123\",\n \"name\": \"Term B\"\n }\n ]\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + terms: Annotated[ + str, + cyclopts.Parameter( + help='Term definitions\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "Term definitions",\n "examples": [\n {\n "glossary_guid": "glossary-guid-123",\n "name": "Revenue",\n "user_description": "Total income"\n },\n [\n {\n "glossary_guid": "g-123",\n "name": "Term A"\n },\n {\n "category_guids": [\n "cat-1"\n ],\n "glossary_guid": "g-123",\n "name": "Term B"\n }\n ]\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Create one or more glossary terms in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Create one or more glossary terms in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" terms_parsed = _maybe_json(terms) - await _call_tool('create_glossary_terms', {'terms': terms_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "create_glossary_terms", + {"terms": terms_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='create_glossary_categories') +@app.command(name="create_glossary_categories") async def create_glossary_categories( *, - categories: Annotated[str, cyclopts.Parameter(help="Category definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Category definitions\",\n \"examples\": [\n {\n \"glossary_guid\": \"glossary-guid-123\",\n \"name\": \"Finance Terms\"\n },\n [\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Cat A\"\n },\n {\n \"glossary_guid\": \"g-123\",\n \"name\": \"Cat B\",\n \"parent_category_guid\": \"cat-a-guid\"\n }\n ]\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + categories: Annotated[ + str, + cyclopts.Parameter( + help='Category definitions\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "Category definitions",\n "examples": [\n {\n "glossary_guid": "glossary-guid-123",\n "name": "Finance Terms"\n },\n [\n {\n "glossary_guid": "g-123",\n "name": "Cat A"\n },\n {\n "glossary_guid": "g-123",\n "name": "Cat B",\n "parent_category_guid": "cat-a-guid"\n }\n ]\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Create one or more glossary categories in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Create one or more glossary categories in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" categories_parsed = _maybe_json(categories) - await _call_tool('create_glossary_categories', {'categories': categories_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "create_glossary_categories", + {"categories": categories_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='create_domains') +@app.command(name="create_domains") async def create_domains( *, - domains: Annotated[str, cyclopts.Parameter(help="Domain definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Domain definitions\",\n \"examples\": [\n {\n \"name\": \"Sales Domain\",\n \"user_description\": \"Sales analytics data\"\n },\n [\n {\n \"name\": \"Finance\"\n },\n {\n \"name\": \"Marketing\",\n \"parent_domain_qualified_name\": \"default/domain/Finance\"\n }\n ]\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + domains: Annotated[ + str, + cyclopts.Parameter( + help='Domain definitions\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "Domain definitions",\n "examples": [\n {\n "name": "Sales Domain",\n "user_description": "Sales analytics data"\n },\n [\n {\n "name": "Finance"\n },\n {\n "name": "Marketing",\n "parent_domain_qualified_name": "default/domain/Finance"\n }\n ]\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Create one or more data domains in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Create one or more data domains in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" domains_parsed = _maybe_json(domains) - await _call_tool('create_domains', {'domains': domains_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "create_domains", + {"domains": domains_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='create_data_products') +@app.command(name="create_data_products") async def create_data_products( *, - products: Annotated[str, cyclopts.Parameter(help="Product definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Product definitions\",\n \"examples\": [\n {\n \"asset_guids\": [\n \"asset-guid-1\"\n ],\n \"domain_qualified_name\": \"default/domain/Sales\",\n \"name\": \"Customer 360\"\n },\n [\n {\n \"asset_guids\": [\n \"g1\",\n \"g2\"\n ],\n \"domain_qualified_name\": \"default/domain/D1\",\n \"name\": \"Prod A\",\n \"user_description\": \"Product A\"\n }\n ]\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + products: Annotated[ + str, + cyclopts.Parameter( + help='Product definitions\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "Product definitions",\n "examples": [\n {\n "asset_guids": [\n "asset-guid-1"\n ],\n "domain_qualified_name": "default/domain/Sales",\n "name": "Customer 360"\n },\n [\n {\n "asset_guids": [\n "g1",\n "g2"\n ],\n "domain_qualified_name": "default/domain/D1",\n "name": "Prod A",\n "user_description": "Product A"\n }\n ]\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Create one or more data products in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Create one or more data products in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" products_parsed = _maybe_json(products) - await _call_tool('create_data_products', {'products': products_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "create_data_products", + {"products": products_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='create_dq_rules_tool') +@app.command(name="create_dq_rules_tool") async def create_dq_rules_tool( *, - rules: Annotated[str, cyclopts.Parameter(help="DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is 'Custom SQL'. rule_name (str): display name, required when rule_type is 'Custom SQL'. dimension (str): required when rule_type is 'Custom SQL': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is 'Custom SQL'. rule_name (str): display name, required when rule_type is 'Custom SQL'. dimension (str): required when rule_type is 'Custom SQL': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.\",\n \"examples\": [\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/table/column\",\n \"rule_type\": \"Null Count\",\n \"threshold_compare_operator\": \"EQ\",\n \"threshold_value\": 0\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"rule_type\": \"Row Count\",\n \"threshold_compare_operator\": \"GTE\",\n \"threshold_value\": 100\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"custom_sql\": \"SELECT COUNT(*) FROM table\",\n \"dimension\": \"COMPLETENESS\",\n \"rule_name\": \"MyRule\",\n \"rule_type\": \"Custom SQL\",\n \"threshold_compare_operator\": \"EQ\",\n \"threshold_value\": 0\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/source_table\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/target_table\",\n \"rule_type\": \"Recon Row Count\",\n \"threshold_unit\": \"PERCENTAGE\",\n \"threshold_value\": 3\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/source_table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/source_table/col\",\n \"reference_column_qualified_name\": \"default/snowflake/db/schema/target_table/col\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/target_table\",\n \"rule_type\": \"Recon Unique Count\",\n \"threshold_unit\": \"PERCENTAGE\",\n \"threshold_value\": 2\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"column_qualified_name\": \"default/snowflake/db/schema/table/country\",\n \"reference_column_qualified_name\": \"default/snowflake/db/schema/valid_countries/code\",\n \"reference_dataset_qualified_name\": \"default/snowflake/db/schema/valid_countries\",\n \"rule_type\": \"Valid Values Reference\",\n \"threshold_value\": 2\n }\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + rules: Annotated[ + str, + cyclopts.Parameter( + help='DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is \'Custom SQL\'. rule_name (str): display name, required when rule_type is \'Custom SQL\'. dimension (str): required when rule_type is \'Custom SQL\': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "DQ rule definitions. Use EXACTLY these field names: rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number, REQUIRED): threshold for the rule. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). alert_priority (str): URGENT, NORMAL, LOW. column_qualified_name (str): required for column-level rules. custom_sql (str): SQL query, required when rule_type is \'Custom SQL\'. rule_name (str): display name, required when rule_type is \'Custom SQL\'. dimension (str): required when rule_type is \'Custom SQL\': COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. reference_dataset_qualified_name (str): required for Recon and Reference rules. reference_column_qualified_name (str): required for column-level Recon/Reference rules. description (str): optional rule description.",\n "examples": [\n {\n "asset_qualified_name": "default/snowflake/db/schema/table",\n "column_qualified_name": "default/snowflake/db/schema/table/column",\n "rule_type": "Null Count",\n "threshold_compare_operator": "EQ",\n "threshold_value": 0\n },\n {\n "asset_qualified_name": "default/snowflake/db/schema/table",\n "rule_type": "Row Count",\n "threshold_compare_operator": "GTE",\n "threshold_value": 100\n },\n {\n "asset_qualified_name": "default/snowflake/db/schema/table",\n "custom_sql": "SELECT COUNT(*) FROM table",\n "dimension": "COMPLETENESS",\n "rule_name": "MyRule",\n "rule_type": "Custom SQL",\n "threshold_compare_operator": "EQ",\n "threshold_value": 0\n },\n {\n "asset_qualified_name": "default/snowflake/db/schema/source_table",\n "reference_dataset_qualified_name": "default/snowflake/db/schema/target_table",\n "rule_type": "Recon Row Count",\n "threshold_unit": "PERCENTAGE",\n "threshold_value": 3\n },\n {\n "asset_qualified_name": "default/snowflake/db/schema/source_table",\n "column_qualified_name": "default/snowflake/db/schema/source_table/col",\n "reference_column_qualified_name": "default/snowflake/db/schema/target_table/col",\n "reference_dataset_qualified_name": "default/snowflake/db/schema/target_table",\n "rule_type": "Recon Unique Count",\n "threshold_unit": "PERCENTAGE",\n "threshold_value": 2\n },\n {\n "asset_qualified_name": "default/snowflake/db/schema/table",\n "column_qualified_name": "default/snowflake/db/schema/table/country",\n "reference_column_qualified_name": "default/snowflake/db/schema/valid_countries/code",\n "reference_dataset_qualified_name": "default/snowflake/db/schema/valid_countries",\n "rule_type": "Valid Values Reference",\n "threshold_value": 2\n }\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Create data quality rules in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Create data quality rules in Atlan. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" rules_parsed = _maybe_json(rules) - await _call_tool('create_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "create_dq_rules_tool", + {"rules": rules_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='schedule_dq_rules_tool') +@app.command(name="schedule_dq_rules_tool") async def schedule_dq_rules_tool( *, - schedules: Annotated[str, cyclopts.Parameter(help="Schedule definitions\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Schedule definitions\",\n \"examples\": [\n {\n \"asset_name\": \"customers\",\n \"asset_qualified_name\": \"default/snowflake/db/schema/customers\",\n \"asset_type\": \"Table\",\n \"schedule_crontab\": \"0 6 * * *\",\n \"schedule_time_zone\": \"America/New_York\"\n },\n [\n {\n \"asset_name\": \"v1\",\n \"asset_qualified_name\": \"qn1\",\n \"asset_type\": \"View\",\n \"schedule_crontab\": \"0 */4 * * *\",\n \"schedule_time_zone\": \"UTC\"\n }\n ]\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + schedules: Annotated[ + str, + cyclopts.Parameter( + help='Schedule definitions\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "Schedule definitions",\n "examples": [\n {\n "asset_name": "customers",\n "asset_qualified_name": "default/snowflake/db/schema/customers",\n "asset_type": "Table",\n "schedule_crontab": "0 6 * * *",\n "schedule_time_zone": "America/New_York"\n },\n [\n {\n "asset_name": "v1",\n "asset_qualified_name": "qn1",\n "asset_type": "View",\n "schedule_crontab": "0 */4 * * *",\n "schedule_time_zone": "UTC"\n }\n ]\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Schedule data quality rule execution on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Schedule data quality rule execution on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" schedules_parsed = _maybe_json(schedules) - await _call_tool('schedule_dq_rules_tool', {'schedules': schedules_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "schedule_dq_rules_tool", + {"schedules": schedules_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='update_dq_rules_tool') +@app.command(name="update_dq_rules_tool") async def update_dq_rules_tool( *, - rules: Annotated[str, cyclopts.Parameter(help="DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n \"type\": \"array\"\n },\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.\",\n \"examples\": [\n {\n \"alert_priority\": \"URGENT\",\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"qualified_name\": \"default/dq/rule-qn\",\n \"rule_type\": \"Null Count\",\n \"threshold_value\": 5\n },\n {\n \"asset_qualified_name\": \"default/snowflake/db/schema/table\",\n \"qualified_name\": \"default/dq/rule-qn\",\n \"rule_type\": \"Freshness\",\n \"threshold_unit\": \"HOURS\",\n \"threshold_value\": 24\n }\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + rules: Annotated[ + str, + cyclopts.Parameter( + help='DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "additionalProperties": true,\n "type": "object"\n },\n "type": "array"\n },\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "type": "string"\n }\n ],\n "description": "DQ rule updates. Use EXACTLY these field names: qualified_name (str, REQUIRED): qualified name of the rule to update. rule_type (str, REQUIRED): Null Count, Null Percentage, Blank Count, Blank Percentage, Min Value, Max Value, Average, Standard Deviation, Unique Count, Duplicate Count, Regex, String Length, Valid Values, Valid Values Reference, Freshness, Row Count, Custom SQL, Recon Row Count, Recon Average, Recon Sum, Recon Duplicate Count, Recon Unique Count. asset_qualified_name (str, REQUIRED): qualified name of the table/view. threshold_value (number): new threshold value. threshold_compare_operator (str): EQUAL (EQ), GREATER_THAN_EQUAL (GTE), LESS_THAN_EQUAL (LTE), BETWEEN (BETWEEN), GREATER_THAN (GT), LESS_THAN (LT). threshold_unit (str): PERCENTAGE, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, ABSOLUTE. alert_priority (str): URGENT, NORMAL, LOW. custom_sql (str): updated SQL query for Custom SQL rules. rule_name (str): updated display name for Custom SQL rules. dimension (str): for Custom SQL rules: COMPLETENESS, TIMELINESS, ACCURACY, CONSISTENCY, UNIQUENESS, VALIDITY, VOLUME. description (str): optional rule description.",\n "examples": [\n {\n "alert_priority": "URGENT",\n "asset_qualified_name": "default/snowflake/db/schema/table",\n "qualified_name": "default/dq/rule-qn",\n "rule_type": "Null Count",\n "threshold_value": 5\n },\n {\n "asset_qualified_name": "default/snowflake/db/schema/table",\n "qualified_name": "default/dq/rule-qn",\n "rule_type": "Freshness",\n "threshold_unit": "HOURS",\n "threshold_value": 24\n }\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Update existing data quality rules. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Update existing data quality rules. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" rules_parsed = _maybe_json(rules) - await _call_tool('update_dq_rules_tool', {'rules': rules_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "update_dq_rules_tool", + {"rules": rules_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='delete_dq_rules_tool') +@app.command(name="delete_dq_rules_tool") async def delete_dq_rules_tool( *, - rule_guids: Annotated[str, cyclopts.Parameter(help="GUIDs of DQ rules to delete\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"GUIDs of DQ rules to delete\"\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + rule_guids: Annotated[ + str, + cyclopts.Parameter( + help='GUIDs of DQ rules to delete\\nJSON Schema: {\n "anyOf": [\n {\n "items": {\n "type": "string"\n },\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "GUIDs of DQ rules to delete"\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Delete data quality rules by their GUIDs. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Delete data quality rules by their GUIDs. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" rule_guids_parsed = _maybe_json(rule_guids) - await _call_tool('delete_dq_rules_tool', {'rule_guids': rule_guids_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "delete_dq_rules_tool", + {"rule_guids": rule_guids_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='manage_asset_lifecycle_tool') +@app.command(name="manage_asset_lifecycle_tool") async def manage_asset_lifecycle_tool( *, - operation: Annotated[str, cyclopts.Parameter(help="Lifecycle operation to perform")], - guids: Annotated[str | None, cyclopts.Parameter(help="Asset GUIDs (required for ARCHIVE/PURGE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset GUIDs (required for ARCHIVE/PURGE)\"\n }")] = None, - asset_type: Annotated[str | None, cyclopts.Parameter(help="Asset type (required for RESTORE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"enum\": [\n \"Table\",\n \"Column\",\n \"View\",\n \"Database\",\n \"Schema\",\n \"MaterializedView\",\n \"AtlasGlossary\",\n \"AtlasGlossaryTerm\",\n \"AtlasGlossaryCategory\",\n \"Connection\",\n \"Process\",\n \"Query\",\n \"Dashboard\",\n \"Report\",\n \"DataDomain\",\n \"DataProduct\",\n \"PowerBIReport\",\n \"PowerBIDataset\",\n \"PowerBIDashboard\",\n \"PowerBIWorkspace\",\n \"PowerBIDataflow\",\n \"PowerBITable\",\n \"PowerBIMeasure\",\n \"PowerBIColumn\",\n \"PowerBIPage\",\n \"DbtModel\",\n \"DbtTest\",\n \"DbtSource\",\n \"DbtMetric\",\n \"DbtModelColumn\",\n \"TableauDashboard\",\n \"TableauCalculatedField\",\n \"TableauWorkbook\",\n \"TableauDatasource\",\n \"TableauProject\",\n \"TableauWorksheet\",\n \"TableauSite\",\n \"TableauFlow\",\n \"LookerDashboard\",\n \"LookerExplore\",\n \"LookerLook\",\n \"LookerView\",\n \"LookerModel\",\n \"LookerProject\",\n \"LookerQuery\",\n \"LookerFolder\",\n \"LookerTile\",\n \"LookerField\",\n \"SigmaWorkbook\",\n \"SigmaDataset\",\n \"SigmaDataElement\",\n \"SigmaPage\",\n \"ThoughtspotLiveboard\",\n \"ThoughtspotAnswer\",\n \"ThoughtspotWorksheet\",\n \"MetabaseDashboard\",\n \"MetabaseQuestion\",\n \"MetabaseCollection\",\n \"QuickSightDashboard\",\n \"QuickSightDataset\",\n \"QuickSightAnalysis\",\n \"PresetDashboard\",\n \"PresetChart\",\n \"PresetDataset\",\n \"MicroStrategyDossier\",\n \"MicroStrategyReport\",\n \"ModeReport\",\n \"ModeQuery\",\n \"ModeWorkspace\",\n \"DomoDashboard\",\n \"DomoDataset\",\n \"DomoCard\",\n \"SisenseDashboard\",\n \"SisenseDatamodel\",\n \"RedashDashboard\",\n \"RedashQuery\",\n \"QlikApp\",\n \"QlikDataset\",\n \"QlikSheet\",\n \"QlikSpace\"\n ],\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Asset type (required for RESTORE)\"\n }")] = None, - qualified_name: Annotated[str | None, cyclopts.Parameter(help="Qualified name (required for RESTORE)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Qualified name (required for RESTORE)\"\n }")] = None, - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + operation: Annotated[ + str, cyclopts.Parameter(help="Lifecycle operation to perform") + ], + guids: Annotated[ + str | None, + cyclopts.Parameter( + help='Asset GUIDs (required for ARCHIVE/PURGE)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Asset GUIDs (required for ARCHIVE/PURGE)"\n }' + ), + ] = None, + asset_type: Annotated[ + str | None, + cyclopts.Parameter( + help='Asset type (required for RESTORE)\\nJSON Schema: {\n "anyOf": [\n {\n "enum": [\n "Table",\n "Column",\n "View",\n "Database",\n "Schema",\n "MaterializedView",\n "AtlasGlossary",\n "AtlasGlossaryTerm",\n "AtlasGlossaryCategory",\n "Connection",\n "Process",\n "Query",\n "Dashboard",\n "Report",\n "DataDomain",\n "DataProduct",\n "PowerBIReport",\n "PowerBIDataset",\n "PowerBIDashboard",\n "PowerBIWorkspace",\n "PowerBIDataflow",\n "PowerBITable",\n "PowerBIMeasure",\n "PowerBIColumn",\n "PowerBIPage",\n "DbtModel",\n "DbtTest",\n "DbtSource",\n "DbtMetric",\n "DbtModelColumn",\n "TableauDashboard",\n "TableauCalculatedField",\n "TableauWorkbook",\n "TableauDatasource",\n "TableauProject",\n "TableauWorksheet",\n "TableauSite",\n "TableauFlow",\n "LookerDashboard",\n "LookerExplore",\n "LookerLook",\n "LookerView",\n "LookerModel",\n "LookerProject",\n "LookerQuery",\n "LookerFolder",\n "LookerTile",\n "LookerField",\n "SigmaWorkbook",\n "SigmaDataset",\n "SigmaDataElement",\n "SigmaPage",\n "ThoughtspotLiveboard",\n "ThoughtspotAnswer",\n "ThoughtspotWorksheet",\n "MetabaseDashboard",\n "MetabaseQuestion",\n "MetabaseCollection",\n "QuickSightDashboard",\n "QuickSightDataset",\n "QuickSightAnalysis",\n "PresetDashboard",\n "PresetChart",\n "PresetDataset",\n "MicroStrategyDossier",\n "MicroStrategyReport",\n "ModeReport",\n "ModeQuery",\n "ModeWorkspace",\n "DomoDashboard",\n "DomoDataset",\n "DomoCard",\n "SisenseDashboard",\n "SisenseDatamodel",\n "RedashDashboard",\n "RedashQuery",\n "QlikApp",\n "QlikDataset",\n "QlikSheet",\n "QlikSpace"\n ],\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Asset type (required for RESTORE)"\n }' + ), + ] = None, + qualified_name: Annotated[ + str | None, + cyclopts.Parameter( + help='Qualified name (required for RESTORE)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Qualified name (required for RESTORE)"\n }' + ), + ] = None, + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Manage asset lifecycle: archive, restore, or permanently purge assets. WARNING: PURGE cannot be undone. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - - await _call_tool('manage_asset_lifecycle_tool', {'operation': operation, 'guids': guids, 'asset_type': asset_type, 'qualified_name': qualified_name, 'mode': mode, 'user_query': user_query}) - - -@app.command(name='manage_announcements_tool') + """Manage asset lifecycle: archive, restore, or permanently purge assets. WARNING: PURGE cannot be undone. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" + + await _call_tool( + "manage_asset_lifecycle_tool", + { + "operation": operation, + "guids": guids, + "asset_type": asset_type, + "qualified_name": qualified_name, + "mode": mode, + "user_query": user_query, + }, + ) + + +@app.command(name="manage_announcements_tool") async def manage_announcements_tool( *, - asset_guids: Annotated[str, cyclopts.Parameter(help="Asset GUIDs to update (comma-separated or JSON array)")], + asset_guids: Annotated[ + str, + cyclopts.Parameter( + help="Asset GUIDs to update (comma-separated or JSON array)" + ), + ], operation: Annotated[str, cyclopts.Parameter(help="Announcement operation")], - announcement_type: Annotated[str | None, cyclopts.Parameter(help="Announcement type (required for SET)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"enum\": [\n \"information\",\n \"warning\",\n \"issue\"\n ],\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Announcement type (required for SET)\"\n }")] = None, - title: Annotated[str | None, cyclopts.Parameter(help="Announcement title (required for SET)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Announcement title (required for SET)\"\n }")] = None, - message: Annotated[str | None, cyclopts.Parameter(help="Announcement message (optional)\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Announcement message (optional)\"\n }")] = None, - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + announcement_type: Annotated[ + str | None, + cyclopts.Parameter( + help='Announcement type (required for SET)\\nJSON Schema: {\n "anyOf": [\n {\n "enum": [\n "information",\n "warning",\n "issue"\n ],\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Announcement type (required for SET)"\n }' + ), + ] = None, + title: Annotated[ + str | None, + cyclopts.Parameter( + help='Announcement title (required for SET)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Announcement title (required for SET)"\n }' + ), + ] = None, + message: Annotated[ + str | None, + cyclopts.Parameter( + help='Announcement message (optional)\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "Announcement message (optional)"\n }' + ), + ] = None, + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Add or remove announcements (information, warning, issue) on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' - - await _call_tool('manage_announcements_tool', {'asset_guids': asset_guids, 'operation': operation, 'announcement_type': announcement_type, 'title': title, 'message': message, 'mode': mode, 'user_query': user_query}) - - -@app.command(name='update_custom_metadata_tool') + """Add or remove announcements (information, warning, issue) on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" + + await _call_tool( + "manage_announcements_tool", + { + "asset_guids": asset_guids, + "operation": operation, + "announcement_type": announcement_type, + "title": title, + "message": message, + "mode": mode, + "user_query": user_query, + }, + ) + + +@app.command(name="update_custom_metadata_tool") async def update_custom_metadata_tool( *, - updates: Annotated[str, cyclopts.Parameter(help="Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {\"guid\": \"...\", \"custom_metadata_name\": \"CM\", \"attributes\": {\"k\": \"v\"}}. Batch: [{\"guid\": \"g1\", ...}, {\"guid\": \"g2\", ...}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {\\\"guid\\\": \\\"...\\\", \\\"custom_metadata_name\\\": \\\"CM\\\", \\\"attributes\\\": {\\\"k\\\": \\\"v\\\"}}. Batch: [{\\\"guid\\\": \\\"g1\\\", ...}, {\\\"guid\\\": \\\"g2\\\", ...}]\"\n }")], - replace: Annotated[bool, cyclopts.Parameter(help="If False (default): partial update — only specified attributes are changed. If True: full replacement — ALL attributes in the CM set are replaced; unspecified attributes are cleared. replace=True only supported for single asset (dict input), not batch.")] = False, - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + updates: Annotated[ + str, + cyclopts.Parameter( + help='Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {"guid": "...", "custom_metadata_name": "CM", "attributes": {"k": "v"}}. Batch: [{"guid": "g1", ...}, {"guid": "g2", ...}]\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "Single update (dict) or batch updates (list of dicts). Each item requires: guid, custom_metadata_name, attributes. Single: {\\"guid\\": \\"...\\", \\"custom_metadata_name\\": \\"CM\\", \\"attributes\\": {\\"k\\": \\"v\\"}}. Batch: [{\\"guid\\": \\"g1\\", ...}, {\\"guid\\": \\"g2\\", ...}]"\n }' + ), + ], + replace: Annotated[ + bool, + cyclopts.Parameter( + help="If False (default): partial update — only specified attributes are changed. If True: full replacement — ALL attributes in the CM set are replaced; unspecified attributes are cleared. replace=True only supported for single asset (dict input), not batch." + ), + ] = False, + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Update custom metadata on one or more assets. Set replace=True for full replacement, False (default) for partial update. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Update custom metadata on one or more assets. Set replace=True for full replacement, False (default) for partial update. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" updates_parsed = _maybe_json(updates) - await _call_tool('update_custom_metadata_tool', {'updates': updates_parsed, 'replace': replace, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "update_custom_metadata_tool", + { + "updates": updates_parsed, + "replace": replace, + "mode": mode, + "user_query": user_query, + }, + ) -@app.command(name='remove_custom_metadata_tool') +@app.command(name="remove_custom_metadata_tool") async def remove_custom_metadata_tool( *, guid: Annotated[str, cyclopts.Parameter(help="GUID of the asset")], - custom_metadata_name: Annotated[str, cyclopts.Parameter(help="Name of the custom metadata set to remove")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + custom_metadata_name: Annotated[ + str, cyclopts.Parameter(help="Name of the custom metadata set to remove") + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Remove a custom metadata set from an asset. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Remove a custom metadata set from an asset. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" - await _call_tool('remove_custom_metadata_tool', {'guid': guid, 'custom_metadata_name': custom_metadata_name, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "remove_custom_metadata_tool", + { + "guid": guid, + "custom_metadata_name": custom_metadata_name, + "mode": mode, + "user_query": user_query, + }, + ) -@app.command(name='create_custom_metadata_set_tool') +@app.command(name="create_custom_metadata_set_tool") async def create_custom_metadata_set_tool( *, - sets: Annotated[str, cyclopts.Parameter(help="Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {\"display_name\": \"Data Quality\", \"attributes\": [{\"display_name\": \"Score\", \"attribute_type\": \"int\"}, {\"display_name\": \"Dimension\", \"attribute_type\": \"enum\", \"enum_values\": [\"Accuracy\", \"Completeness\"]}]}. Batch: [{\"display_name\": \"CM1\", \"attributes\": [...]}, {\"display_name\": \"CM2\", \"attributes\": [...]}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {\\\"display_name\\\": \\\"Data Quality\\\", \\\"attributes\\\": [{\\\"display_name\\\": \\\"Score\\\", \\\"attribute_type\\\": \\\"int\\\"}, {\\\"display_name\\\": \\\"Dimension\\\", \\\"attribute_type\\\": \\\"enum\\\", \\\"enum_values\\\": [\\\"Accuracy\\\", \\\"Completeness\\\"]}]}. Batch: [{\\\"display_name\\\": \\\"CM1\\\", \\\"attributes\\\": [...]}, {\\\"display_name\\\": \\\"CM2\\\", \\\"attributes\\\": [...]}]\"\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + sets: Annotated[ + str, + cyclopts.Parameter( + help='Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {"display_name": "Data Quality", "attributes": [{"display_name": "Score", "attribute_type": "int"}, {"display_name": "Dimension", "attribute_type": "enum", "enum_values": ["Accuracy", "Completeness"]}]}. Batch: [{"display_name": "CM1", "attributes": [...]}, {"display_name": "CM2", "attributes": [...]}]\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "Single CM set (dict) or multiple CM sets (list of dicts). Each requires: display_name (str), attributes (list). Optional: description (str). Each attribute requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional per attribute: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Single: {\\"display_name\\": \\"Data Quality\\", \\"attributes\\": [{\\"display_name\\": \\"Score\\", \\"attribute_type\\": \\"int\\"}, {\\"display_name\\": \\"Dimension\\", \\"attribute_type\\": \\"enum\\", \\"enum_values\\": [\\"Accuracy\\", \\"Completeness\\"]}]}. Batch: [{\\"display_name\\": \\"CM1\\", \\"attributes\\": [...]}, {\\"display_name\\": \\"CM2\\", \\"attributes\\": [...]}]"\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Create one or more custom metadata sets with typed attributes. Defines the schema; use update_custom_metadata to set values on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Create one or more custom metadata sets with typed attributes. Defines the schema; use update_custom_metadata to set values on assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" sets_parsed = _maybe_json(sets) - await _call_tool('create_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "create_custom_metadata_set_tool", + {"sets": sets_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='delete_custom_metadata_set_tool') +@app.command(name="delete_custom_metadata_set_tool") async def delete_custom_metadata_set_tool( *, - sets: Annotated[str, cyclopts.Parameter(help="Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: \"Data Quality\". Batch: [\"Data Quality\", \"Governance\"]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n }\n ],\n \"description\": \"Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: \\\"Data Quality\\\". Batch: [\\\"Data Quality\\\", \\\"Governance\\\"]\"\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + sets: Annotated[ + str, + cyclopts.Parameter( + help='Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: "Data Quality". Batch: ["Data Quality", "Governance"]\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "items": {},\n "type": "array"\n }\n ],\n "description": "Single CM set display name (string) or list of display names for batch deletion. WARNING: Irreversible — deletes the schema and all stored attribute values on assets. Single: \\"Data Quality\\". Batch: [\\"Data Quality\\", \\"Governance\\"]"\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Permanently delete one or more custom metadata sets and all their attribute values from assets. Irreversible. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Permanently delete one or more custom metadata sets and all their attribute values from assets. Irreversible. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" sets_parsed = _maybe_json(sets) - await _call_tool('delete_custom_metadata_set_tool', {'sets': sets_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "delete_custom_metadata_set_tool", + {"sets": sets_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='add_attributes_to_cm_set_tool') +@app.command(name="add_attributes_to_cm_set_tool") async def add_attributes_to_cm_set_tool( *, - display_name: Annotated[str, cyclopts.Parameter(help="Display name of the existing CM set")], - attributes: Annotated[str, cyclopts.Parameter(help="Attributes to add. Each requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Example: [{\"display_name\": \"Risk Score\", \"attribute_type\": \"int\"}, {\"display_name\": \"Status\", \"attribute_type\": \"enum\", \"enum_values\": [\"Draft\", \"Approved\", \"Rejected\"]}]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Attributes to add. Each requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Example: [{\\\"display_name\\\": \\\"Risk Score\\\", \\\"attribute_type\\\": \\\"int\\\"}, {\\\"display_name\\\": \\\"Status\\\", \\\"attribute_type\\\": \\\"enum\\\", \\\"enum_values\\\": [\\\"Draft\\\", \\\"Approved\\\", \\\"Rejected\\\"]}]\"\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + display_name: Annotated[ + str, cyclopts.Parameter(help="Display name of the existing CM set") + ], + attributes: Annotated[ + str, + cyclopts.Parameter( + help='Attributes to add. Each requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Example: [{"display_name": "Risk Score", "attribute_type": "int"}, {"display_name": "Status", "attribute_type": "enum", "enum_values": ["Draft", "Approved", "Rejected"]}]\\nJSON Schema: {\n "anyOf": [\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "Attributes to add. Each requires: display_name, attribute_type (string|int|float|boolean|date|enum|users|groups|url|SQL|long). Optional: multi_valued (bool), description (str). For enum attributes: provide enum_values (list of allowed strings) to create a new enum, OR options_name (str) to reference an existing Atlan enum. Example: [{\\"display_name\\": \\"Risk Score\\", \\"attribute_type\\": \\"int\\"}, {\\"display_name\\": \\"Status\\", \\"attribute_type\\": \\"enum\\", \\"enum_values\\": [\\"Draft\\", \\"Approved\\", \\"Rejected\\"]}]"\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Add new typed attributes to an existing custom metadata set. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Add new typed attributes to an existing custom metadata set. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" attributes_parsed = _maybe_json(attributes) - await _call_tool('add_attributes_to_cm_set_tool', {'display_name': display_name, 'attributes': attributes_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "add_attributes_to_cm_set_tool", + { + "display_name": display_name, + "attributes": attributes_parsed, + "mode": mode, + "user_query": user_query, + }, + ) -@app.command(name='remove_attributes_from_cm_set_tool') +@app.command(name="remove_attributes_from_cm_set_tool") async def remove_attributes_from_cm_set_tool( *, - display_name: Annotated[str, cyclopts.Parameter(help="Display name of the existing CM set")], - attribute_names: Annotated[str, cyclopts.Parameter(help="Display name(s) of attributes to remove (archive). Archived attributes are soft-deleted: values are cleared from assets and hidden in UI. Example: [\"Score\", \"Reviewed By\"] or a JSON string of the list.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Display name(s) of attributes to remove (archive). Archived attributes are soft-deleted: values are cleared from assets and hidden in UI. Example: [\\\"Score\\\", \\\"Reviewed By\\\"] or a JSON string of the list.\"\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + display_name: Annotated[ + str, cyclopts.Parameter(help="Display name of the existing CM set") + ], + attribute_names: Annotated[ + str, + cyclopts.Parameter( + help='Display name(s) of attributes to remove (archive). Archived attributes are soft-deleted: values are cleared from assets and hidden in UI. Example: ["Score", "Reviewed By"] or a JSON string of the list.\\nJSON Schema: {\n "anyOf": [\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "Display name(s) of attributes to remove (archive). Archived attributes are soft-deleted: values are cleared from assets and hidden in UI. Example: [\\"Score\\", \\"Reviewed By\\"] or a JSON string of the list."\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Remove (archive) attributes from an existing custom metadata set. Archived attributes are soft-deleted and their values cleared from all assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Remove (archive) attributes from an existing custom metadata set. Archived attributes are soft-deleted and their values cleared from all assets. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" attribute_names_parsed = _maybe_json(attribute_names) - await _call_tool('remove_attributes_from_cm_set_tool', {'display_name': display_name, 'attribute_names': attribute_names_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "remove_attributes_from_cm_set_tool", + { + "display_name": display_name, + "attribute_names": attribute_names_parsed, + "mode": mode, + "user_query": user_query, + }, + ) -@app.command(name='add_atlan_tags_tool') +@app.command(name="add_atlan_tags_tool") async def add_atlan_tags_tool( *, - updates: Annotated[str, cyclopts.Parameter(help="Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {\"guid\": \"...\", \"tag_names\": [\"PII\"]}. Batch: [{\"guid\": \"g1\", \"tag_names\": [\"PII\"]}, ...]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {\\\"guid\\\": \\\"...\\\", \\\"tag_names\\\": [\\\"PII\\\"]}. Batch: [{\\\"guid\\\": \\\"g1\\\", \\\"tag_names\\\": [\\\"PII\\\"]}, ...]\"\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + updates: Annotated[ + str, + cyclopts.Parameter( + help='Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {"guid": "...", "tag_names": ["PII"]}. Batch: [{"guid": "g1", "tag_names": ["PII"]}, ...]\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "Single tag addition (dict) or batch (list of dicts). Each item: {guid, tag_names, propagate (default true), remove_on_delete (default true), restrict_lineage_propagation (default false), restrict_hierarchy_propagation (default false)}. Single: {\\"guid\\": \\"...\\", \\"tag_names\\": [\\"PII\\"]}. Batch: [{\\"guid\\": \\"g1\\", \\"tag_names\\": [\\"PII\\"]}, ...]"\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Add Atlan tags to one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Add Atlan tags to one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" updates_parsed = _maybe_json(updates) - await _call_tool('add_atlan_tags_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "add_atlan_tags_tool", + {"updates": updates_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='remove_atlan_tag_tool') +@app.command(name="remove_atlan_tag_tool") async def remove_atlan_tag_tool( *, - updates: Annotated[str, cyclopts.Parameter(help="Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {\"guid\": \"...\", \"tag_name\": \"PII\"}. Batch: [{\"guid\": \"g1\", \"tag_name\": \"PII\"}, ...]\\nJSON Schema: {\n \"anyOf\": [\n {\n \"additionalProperties\": true,\n \"type\": \"object\"\n },\n {\n \"items\": {},\n \"type\": \"array\"\n },\n {\n \"type\": \"string\"\n }\n ],\n \"description\": \"Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {\\\"guid\\\": \\\"...\\\", \\\"tag_name\\\": \\\"PII\\\"}. Batch: [{\\\"guid\\\": \\\"g1\\\", \\\"tag_name\\\": \\\"PII\\\"}, ...]\",\n \"examples\": [\n {\n \"guid\": \"asset-guid-1\",\n \"tag_name\": \"PII\"\n },\n [\n {\n \"guid\": \"g1\",\n \"tag_name\": \"Sensitive\"\n },\n {\n \"guid\": \"g2\",\n \"tag_name\": \"Confidential\"\n }\n ]\n ]\n }")], - mode: Annotated[str, cyclopts.Parameter(help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved.")] = 'propose', - user_query: Annotated[str | None, cyclopts.Parameter(help="REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"REQUIRED: Always pass the user's exact question/prompt that triggered this tool call. Used for tracing and observability.\"\n }")] = None, + updates: Annotated[ + str, + cyclopts.Parameter( + help='Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {"guid": "...", "tag_name": "PII"}. Batch: [{"guid": "g1", "tag_name": "PII"}, ...]\\nJSON Schema: {\n "anyOf": [\n {\n "additionalProperties": true,\n "type": "object"\n },\n {\n "items": {},\n "type": "array"\n },\n {\n "type": "string"\n }\n ],\n "description": "Single tag removal (dict) or batch (list of dicts). Each item: {guid, tag_name}. Single: {\\"guid\\": \\"...\\", \\"tag_name\\": \\"PII\\"}. Batch: [{\\"guid\\": \\"g1\\", \\"tag_name\\": \\"PII\\"}, ...]",\n "examples": [\n {\n "guid": "asset-guid-1",\n "tag_name": "PII"\n },\n [\n {\n "guid": "g1",\n "tag_name": "Sensitive"\n },\n {\n "guid": "g2",\n "tag_name": "Confidential"\n }\n ]\n ]\n }' + ), + ], + mode: Annotated[ + str, + cyclopts.Parameter( + help="ALWAYS use 'propose'. Returns a preview for user review. NEVER use 'execute' unless the user has explicitly approved." + ), + ] = "propose", + user_query: Annotated[ + str | None, + cyclopts.Parameter( + help='REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability.\\nJSON Schema: {\n "anyOf": [\n {\n "type": "string"\n },\n {\n "type": "null"\n }\n ],\n "default": null,\n "description": "REQUIRED: Always pass the user\'s exact question/prompt that triggered this tool call. Used for tracing and observability."\n }' + ), + ] = None, ) -> None: - '''Remove an Atlan tag from one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.''' + """Remove an Atlan tag from one or more assets. Pass a dict for single asset, list for batch. Always uses propose mode — STOP after proposing and wait for user approval before executing.""" updates_parsed = _maybe_json(updates) - await _call_tool('remove_atlan_tag_tool', {'updates': updates_parsed, 'mode': mode, 'user_query': user_query}) + await _call_tool( + "remove_atlan_tag_tool", + {"updates": updates_parsed, "mode": mode, "user_query": user_query}, + ) -@app.command(name='get_asset_icons') +@app.command(name="get_asset_icons") async def get_asset_icons( *, icon_names: Annotated[list[str], cyclopts.Parameter(help="")], ) -> None: - '''Fetch SVG icons for asset types. Internal tool used by widgets.''' - await _call_tool('get_asset_icons', {'icon_names': icon_names}) + """Fetch SVG icons for asset types. Internal tool used by widgets.""" + await _call_tool("get_asset_icons", {"icon_names": icon_names}) def main() -> None: From c19b02a9325e287548132e684cf19ed2050db7cf Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 18:03:05 +0530 Subject: [PATCH 22/26] ci(mcp-cli): switch publish to PyPI Trusted Publisher (OIDC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds id-token: write permission and removes PYPI_API_TOKEN env var. OIDC trusted publisher is more secure — no long-lived secret needed. Configure the pending publisher at pypi.org with: project: atlan-cli, owner: atlanhq, repo: agent-toolkit, workflow: mcp-cli-publish.yaml Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mcp-cli-publish.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/mcp-cli-publish.yaml b/.github/workflows/mcp-cli-publish.yaml index 160a281..616654d 100644 --- a/.github/workflows/mcp-cli-publish.yaml +++ b/.github/workflows/mcp-cli-publish.yaml @@ -7,6 +7,7 @@ on: permissions: contents: read + id-token: write # required for PyPI Trusted Publisher (OIDC) jobs: publish: @@ -31,6 +32,4 @@ jobs: run: uv build - name: Publish to PyPI - env: - UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} run: uv publish From a245cd83a75c2363609a8100c421621b378135e5 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 18:04:03 +0530 Subject: [PATCH 23/26] ci(mcp-cli): match MCP server release pattern with versioned tags - Triggers on PR merge to main with 'release-cli' label (mirrors 'release' label used by mcp-server-release.yml, separate to avoid double-trigger) - Reads __version__ from mcp-cli/atlan_cli.py - Creates tag mcp-cli-vX.Y.Z, GitHub Release with auto-changelog - Publishes to PyPI via OIDC trusted publisher (no API token secret) - workflow_dispatch still works for manual runs Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/mcp-cli-publish.yaml | 134 ++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 14 deletions(-) diff --git a/.github/workflows/mcp-cli-publish.yaml b/.github/workflows/mcp-cli-publish.yaml index 616654d..c5b95eb 100644 --- a/.github/workflows/mcp-cli-publish.yaml +++ b/.github/workflows/mcp-cli-publish.yaml @@ -1,29 +1,135 @@ -name: Publish atlan-cli to PyPI +name: Publish atlan-cli on: - release: - types: [published] + pull_request: + types: [closed] + branches: + - main workflow_dispatch: -permissions: - contents: read - id-token: write # required for PyPI Trusted Publisher (OIDC) - jobs: + prepare-release: + if: > + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release-cli')) + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.get_version.outputs.version }} + should_release: ${{ steps.check_tag.outputs.exists == 'false' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version + id: get_version + run: | + VERSION=$(grep -m 1 "__version__" mcp-cli/atlan_cli.py | cut -d'"' -f2) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Found version: $VERSION" + + - name: Check if tag exists + id: check_tag + run: | + TAG_NAME="mcp-cli-v${{ steps.get_version.outputs.version }}" + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "Tag $TAG_NAME already exists, stopping workflow" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "Tag $TAG_NAME does not exist, continuing" + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Generate changelog + id: changelog + if: steps.check_tag.outputs.exists == 'false' + run: | + set +e + VERSION="${{ steps.get_version.outputs.version }}" + RELEASE_DATE=$(date +"%Y-%m-%d") + PREV_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD) + RANGE="$FIRST_COMMIT..HEAD" + else + RANGE="$PREV_TAG..HEAD" + fi + + echo "## [$VERSION] - $RELEASE_DATE" > RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + + git log $RANGE --format="* %s (%h)" --grep="^feat" --perl-regexp --no-merges 2>/dev/null > features.txt || touch features.txt + git log $RANGE --format="* %s (%h)" --grep="^fix" --perl-regexp --no-merges 2>/dev/null > fixes.txt || touch fixes.txt + + if [ -s features.txt ]; then + echo "### Added" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + sed 's/^\* feat[[:space:]]*\([^:]*\):[[:space:]]*/\* /' features.txt >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + fi + + if [ -s fixes.txt ]; then + echo "### Fixed" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + sed 's/^\* fix[[:space:]]*\([^:]*\):[[:space:]]*/\* /' fixes.txt >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + fi + + if [ ! -s features.txt ] && [ ! -s fixes.txt ]; then + echo "### Changed" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + echo "* Release version $VERSION" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + fi + + rm -f features.txt fixes.txt + cat RELEASE_NOTES.md + + - name: Create tag + if: steps.check_tag.outputs.exists == 'false' + run: | + git tag mcp-cli-v${{ steps.get_version.outputs.version }} + git push --tags + + - name: Create GitHub Release + if: steps.check_tag.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: mcp-cli-v${{ steps.get_version.outputs.version }} + name: atlan-cli v${{ steps.get_version.outputs.version }} + body_path: RELEASE_NOTES.md + token: ${{ secrets.GITHUB_TOKEN }} + draft: false + prerelease: false + + - name: Upload release notes + if: steps.check_tag.outputs.exists == 'false' + uses: actions/upload-artifact@v4 + with: + name: release-notes + path: RELEASE_NOTES.md + retention-days: 1 + publish: - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') + needs: prepare-release + if: needs.prepare-release.outputs.should_release == 'true' runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for PyPI Trusted Publisher (OIDC) defaults: run: working-directory: mcp-cli - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - name: Checkout + uses: actions/checkout@v4 with: - python-version: "3.12" + ref: mcp-cli-v${{ needs.prepare-release.outputs.version }} - name: Install uv uses: astral-sh/setup-uv@v5 From c6fe72c29c0497b6457967549deb471089efb3f9 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 18:15:33 +0530 Subject: [PATCH 24/26] chore: consolidate mcp-cli gitignore into root, remove PLAN.md - Moved uv.lock, .fastmcp-cache/, .env.* rules into root .gitignore - Removed mcp-cli/.gitignore (redundant now) - Removed mcp-cli/PLAN.md (implementation complete, context lives in git history) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 8 ++ mcp-cli/.gitignore | 22 ----- mcp-cli/PLAN.md | 239 --------------------------------------------- 3 files changed, 8 insertions(+), 261 deletions(-) delete mode 100644 mcp-cli/.gitignore delete mode 100644 mcp-cli/PLAN.md diff --git a/.gitignore b/.gitignore index aa93d3d..0246d45 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,8 @@ celerybeat.pid # Environments .env +.env.* +!.env.example .venv env/ venv/ @@ -165,3 +167,9 @@ cython_debug/ # Plugin build artifacts atlan-claude-plugin.tar.gz atlan-claude-plugin.zip + +# uv lockfile (not committed — reproducibility via pyproject.toml) +uv.lock + +# FastMCP OAuth token cache +.fastmcp-cache/ diff --git a/mcp-cli/.gitignore b/mcp-cli/.gitignore deleted file mode 100644 index a259e04..0000000 --- a/mcp-cli/.gitignore +++ /dev/null @@ -1,22 +0,0 @@ -# Build artifacts -dist/ -build/ -*.egg-info/ - -# Python -__pycache__/ -*.pyc -*.pyo -.venv/ -venv/ - -# Env / secrets -.env -.env.* -!.env.example - -# uv -uv.lock - -# FastMCP OAuth cache -.fastmcp-cache/ diff --git a/mcp-cli/PLAN.md b/mcp-cli/PLAN.md deleted file mode 100644 index 343d431..0000000 --- a/mcp-cli/PLAN.md +++ /dev/null @@ -1,239 +0,0 @@ -# Atlan MCP CLI — Implementation Plan - -> **Status:** P0 in progress. P1/P2 deferred to future PRs. -> **Approach:** Lighter-touch — keep the generated single-file CLI working, extract only auth/runtime seams; defer package-layout churn to P1. - ---- - -## 0. Verified facts (Apr 2026) - -- **`mcp.atlan.com` proxy is already deployed** with refresh-token support: - - `GET /.well-known/oauth-authorization-server` → `grant_types_supported: ["authorization_code", "refresh_token"]` - - `POST /oauth/token` accepts both grants and decodes the refresh-token JWT to derive tenant (`oauth_proxy/app.py:219`) - - `POST /oauth/register` returns static `client_id=mcp-client`, `client_secret=placeholder` -- **Refresh tokens carry tenant info** in their `iss` JWT claim — the CLI never needs to persist tenant URL for OAuth. -- The current generated CLI uses `cyclopts` + `fastmcp.Client` and works against `https://atlan-demo.atlan.com/mcp` (verified end-to-end with the OAuth flow). - ---- - -## 1. End-state UX - -```bash -# One-time setup -atlan login # interactive: pick OAuth or API key -atlan login --oauth # force OAuth, non-interactive -atlan login --api-key sk-xxx --tenant https://demo.atlan.com # api-key mode - -# Daily use — no flags, no env vars -atlan semantic_search_tool --user-query "PII tables" -atlan list-tools -atlan get_asset_tool --guid abc-123 - -# Diagnostics -atlan status # auth mode, tenant, expiry -atlan logout # wipe everything - -# Per-call overrides (still supported) -atlan --oauth semantic_search_tool ... # force fresh OAuth this call -atlan --api-key sk-xxx --tenant https://t.com ... # one-shot API key -``` - -**Exit codes:** -| Code | Meaning | -|------|---------| -| 0 | success | -| 1 | server/tool returned error | -| 2 | auth required or refresh failed → `atlan login` | -| 3 | config / invocation error | - ---- - -## 2. Approach - -**Keep the file shape for P0.** The generated `atlan_cli.py` stays a single file; we only add handwritten helpers for auth, config, and IO. No `src/atlan_cli/` rewrite until UX is validated. This minimizes risk and preserves the regeneration story. - -**Stay with `cyclopts`.** Already working, type-hint native, async-friendly. For prettier output we add `rich` (already a dep) panels/tables and `questionary` for interactive `atlan login` prompts. No need to switch to click/typer. - -**Use the existing keychain library** (`keyring`) — already a dep. JSON-file fallback only when keychain unavailable. - ---- - -## 3. Architecture - -### 3.1 Storage layout - -| Path | Contents | Permissions | -|------|----------|-------------| -| `~/.atlan/config.json` | `{"auth_mode": "oauth"\|"api-key", "tenant": "..." (api-key only), "client_id": "mcp-client"}` | 0600 | -| OS keyring `atlan-mcp/refresh_token` | Long-lived JWT (~30d) | OS-level | -| OS keyring `atlan-mcp/access_token` | Short-lived JWT JSON `{"access_token", "expires_at"}` | OS-level | -| OS keyring `atlan-mcp/api_key` | Atlan API key (api-key mode only) | OS-level | -| `~/.atlan/credentials.json` (fallback) | Same secrets, used only when `keyring` raises | 0600 | - -### 3.2 Auth resolution - -``` - ┌── --api-key override? → BearerAuth(key) → {tenant}/mcp/api-key - │ -resolve_auth() ──┼── --oauth override? → fresh browser flow → BearerAuth(token) - │ (don't persist by default) - │ - └── persisted config? - ├── api-key: read api_key from keyring → BearerAuth → {tenant}/mcp/api-key - └── oauth: - ├── access cached & valid → BearerAuth → mcp.atlan.com/mcp - ├── refresh OK → store new tokens → BearerAuth - └── refresh failed → wipe creds → exit 2 -``` - -### 3.3 Stale-cache wipe invariant - -`wipe_credentials()` is called from exactly **three places**: -1. Refresh fails (`invalid_grant` or HTTP error) — server says token revoked. -2. `atlan logout`. -3. `atlan login` (always wipes before writing — prevents mode-switch cross-contamination). - -Any other call to wipe is a bug. Transient network errors must not wipe credentials. - -### 3.4 Refresh-token grant - -``` -POST https://mcp.atlan.com/oauth/token -Content-Type: application/x-www-form-urlencoded - -grant_type=refresh_token -&refresh_token= -&client_id=mcp-client -&client_secret=placeholder -``` - -Response: -```json -{ - "access_token": "", - "refresh_token": "", - "expires_in": 300, - "token_type": "Bearer" -} -``` - -Store new access token with `expires_at = now + expires_in - 30` (30s safety margin). If response includes a new refresh token (rotation), replace the stored one. - ---- - -## 4. P0 — Demo-ready (this PR) - -| # | Item | File | Status | -|---|------|------|--------| -| 1 | Hardcode `https://mcp.atlan.com` as default OAuth proxy URL | `atlan_cli.py` | ⏳ | -| 2 | `~/.atlan/config.json` reader/writer | `atlan_cli.py` | ⏳ | -| 3 | Keyring secret store with file fallback | `atlan_cli.py` | ⏳ | -| 4 | `_resolve_auth()` rewrite with override → config → refresh hierarchy | `atlan_cli.py` | ⏳ | -| 5 | `atlan login` (interactive + `--oauth` + `--api-key --tenant`) | `atlan_cli.py` | ⏳ | -| 6 | `atlan logout` | `atlan_cli.py` | ⏳ | -| 7 | `atlan status` | `atlan_cli.py` | ⏳ | -| 8 | Refresh-token grant + stale-cache wipe | `atlan_cli.py` | ⏳ | -| 9 | Move `--oauth` / `--api-key` / `--tenant` / `--json` to global flags in `main()` | `atlan_cli.py` | ⏳ | -| 10 | Exit codes 0/1/2/3 | `atlan_cli.py` | ⏳ | -| 11 | `--json` raw output mode | `atlan_cli.py` | ⏳ | -| 12 | `.gitignore` build artifacts; drop tracked `.env`/`.venv`/`uv.lock` | `.gitignore` | ⏳ | -| 13 | Update README to login-first UX | `README.md` | ⏳ | - ---- - -## 5. P1 — Packaging & polish (next sprint) - -- `version.txt` + dynamic version from `importlib.metadata` -- `.github/workflows/mcp-cli-publish.yaml` modeled on `pyatlan-publish.yaml` -- Reserve PyPI name `atlan-cli` (fallback: `atlan-mcp-cli`) -- `uv tool install atlan-cli` — frictionless install for any agent author -- Cyclopts command groups for categorized `--help` (Search / Update / Glossary / DQ / CM / Tags) -- `rich` tables for `list-tools`, syntax-highlighted JSON for tool results -- `questionary` for prettier interactive `atlan login` prompts - ---- - -## 6. P2 — Maintenance & nice-to-haves - -- Separate generated commands from auth/runtime via `_generated.py` import + `Makefile regenerate` target with merge script -- Document the regeneration workflow without breaking auth glue -- `atlan list-tools --category glossary` filtering -- Multi-tenant `~/.atlan/profiles/.json` + `atlan --profile ` -- Mid-stream token refresh for long-running batches (today refresh only happens at request-start boundary) -- Headless device-flow when `DISPLAY` not set -- `atlan completion zsh|bash|fish` - ---- - -## 7. Public interfaces - -**Commands** -- `atlan login [--oauth | --api-key [--tenant ]]` -- `atlan logout` -- `atlan status` -- `atlan list-tools`, `atlan list-resources`, `atlan list-prompts`, `atlan read-resource`, `atlan get-prompt` -- `atlan [tool args]` for every MCP tool - -**Global flags** (consumed in `main()` before cyclopts): -- `--oauth` — force fresh OAuth this invocation -- `--api-key ` — one-shot API key (requires `--tenant`) -- `--tenant ` — tenant URL for one-shot api-key mode -- `--json` — emit raw JSON to stdout, all logs to stderr - -**Config file shape** (`~/.atlan/config.json`): -```json -{ - "auth_mode": "oauth", - "client_id": "mcp-client" -} -``` -or -```json -{ - "auth_mode": "api-key", - "tenant": "https://demo.atlan.com" -} -``` - -**Secrets** -- Never written to repo files, `.env.example`, logs, or world-readable local files. -- Keyring service name: `atlan-mcp`, account names: `refresh_token`, `access_token`, `api_key`. - ---- - -## 8. Test plan (P0) - -| Scenario | Expected | -|----------|----------| -| First `atlan login` (OAuth) | Browser opens, callback succeeds, refresh+access tokens in keyring, config written | -| `atlan semantic_search_tool` after login | Works without flags, uses cached access token | -| Access token expired, refresh succeeds | Tool call works, tokens rotated in keyring | -| Refresh token revoked (manual test) | Exit 2, tokens wiped, config retained, message "run atlan login" | -| `atlan login --api-key sk-x --tenant https://t.com` | Validates against `/api/meta/whoami` (or similar), persists | -| `atlan list-tools` in api-key mode | Works without browser | -| `atlan logout` | Wipes config + all keyring entries | -| Per-call `atlan --oauth …` after logout | Works one-shot, does not persist | -| `atlan --api-key sk-x --tenant https://t.com semantic_search_tool …` after logout | Works one-shot, does not persist | -| `atlan status` unauthenticated | Exit 2, single line: "Not authenticated. Run `atlan login`." | -| `atlan status` authenticated | Exit 0, mode + tenant + expiry | -| `--json` flag | Raw JSON to stdout for both success and error | -| Malformed `~/.atlan/config.json` | Exit 3 with clear message | -| Keyring unavailable | Falls back to `~/.atlan/credentials.json` (0600) | - ---- - -## 9. Assumptions - -- `mcp.atlan.com/oauth/token` continues to support `refresh_token` grant. -- The refresh token JWT continues to encode tenant in the `iss` claim (verified against `oauth_proxy/app.py:219`). -- For api-key validation, we use `{tenant}/api/meta/whoami` — to be confirmed; fall back to attempting an MCP `initialize` if `/api/meta/whoami` is not available. -- The generated tool functions in `atlan_cli.py` are the source of truth for tool argument surfaces in P0. - ---- - -## 10. References - -- Proxy implementation: `agent-toolkit-internal:mcp_proxy:oauth_proxy/app.py` (token endpoint at line 608, refresh handling at 646, JWT issuer extraction at 219) -- Live proxy (verified Apr 28): `https://mcp.atlan.com/.well-known/oauth-authorization-server`, `/oauth/token`, `/oauth/register` -- Pyatlan packaging template: `atlan-python:pyproject.toml`, `.github/workflows/pyatlan-publish.yaml` -- Current CLI: `agent-toolkit:feat/mcp-cli:mcp-cli/atlan_cli.py` From cd83333ba62db86f8b1cb0c5faa5a634f5347dbc Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 18:21:15 +0530 Subject: [PATCH 25/26] chore: revert unnecessary gitignore additions to root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep only plugin artifact patterns (.tar.gz, .zip) which cover real untracked files. Remove .env.*, uv.lock, .fastmcp-cache/ — not needed at root level. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.gitignore b/.gitignore index 0246d45..aa93d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -121,8 +121,6 @@ celerybeat.pid # Environments .env -.env.* -!.env.example .venv env/ venv/ @@ -167,9 +165,3 @@ cython_debug/ # Plugin build artifacts atlan-claude-plugin.tar.gz atlan-claude-plugin.zip - -# uv lockfile (not committed — reproducibility via pyproject.toml) -uv.lock - -# FastMCP OAuth token cache -.fastmcp-cache/ From e328703701fcec641377ba8071aa45af0aa72c72 Mon Sep 17 00:00:00 2001 From: Abhinav Mathur Date: Tue, 28 Apr 2026 18:22:48 +0530 Subject: [PATCH 26/26] chore: remove plugin artifact patterns from gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not needed — these are stray local files, not repo concerns. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index aa93d3d..b1a0109 100644 --- a/.gitignore +++ b/.gitignore @@ -161,7 +161,3 @@ cython_debug/ .vscode/ .DS_Store - -# Plugin build artifacts -atlan-claude-plugin.tar.gz -atlan-claude-plugin.zip