From 6cc5d554678186bcc5e1e97859cfacfd5296816d Mon Sep 17 00:00:00 2001 From: Chris Hill Date: Thu, 27 Aug 2026 05:56:02 +0000 Subject: [PATCH] Upgrade to SDK v0.2.19: resource type tools, dependency visibility, and new list filters Adds tools for the resource type catalog and the connection system, and drops service account creation. New tools: - get_resource_type: returns the JSON schema a create_resource payload must satisfy, plus uiSchema, markdown import instructions, connection orientation, and effective attributes. Accepts version pinning (@1.2.3, @~1, @latest). - list_resource_type_dependents: what depends on a resource type in an environment, one entry per (instance, dependency field) pair. - list_unfulfilled_dependencies: required inputs nothing fills, so a blocked deploy is diagnosable. - list_environment_links: the links in effect given the versions each environment actually runs. - get_organization_settings / update_organization_settings. Removed create_service_account. It mints a bearer credential and returns it in the response, which does not belong behind a tool call. List filters: attributes on list_resources, list_oci_repos, and list_instances; created_after/created_before on list_resources, list_oci_repos, and list_projects. Timestamps parse as RFC 3339 and a malformed bound errors rather than silently widening the result set. Also fixes the v0.2.19 rename of serviceaccounts.Created to ServiceAccountWithToken, which broke the build. Co-Authored-By: Claude Opus 5 (1M context) --- MCP_README.md | 16 ++- README.md | 17 ++- go.mod | 2 +- go.sum | 2 + main_test.go | 8 +- mcp/server.go | 10 +- mcp/structured_content_test.go | 26 +++- mcp/tools/components.go | 7 +- mcp/tools/environments.go | 66 +++++++++ mcp/tools/environments_test.go | 139 +++++++++++++++++++ mcp/tools/helpers.go | 38 ++++++ mcp/tools/helpers_test.go | 56 ++++++++ mcp/tools/instances.go | 14 +- mcp/tools/instances_test.go | 21 +++ mcp/tools/metadata.go | 13 +- mcp/tools/metadata_test.go | 10 +- mcp/tools/oci_repos.go | 37 ++++-- mcp/tools/oci_repos_test.go | 28 ++++ mcp/tools/organizations.go | 72 ++++++++++ mcp/tools/organizations_test.go | 117 ++++++++++++++++ mcp/tools/projects.go | 33 +++-- mcp/tools/projects_test.go | 22 +++ mcp/tools/resource_types.go | 71 ++++++++++ mcp/tools/resource_types_test.go | 206 +++++++++++++++++++++++++++++ mcp/tools/resources.go | 39 ++++-- mcp/tools/resources_test.go | 45 +++++++ mcp/tools/schema_required_test.go | 7 +- mcp/tools/service_accounts.go | 37 ------ mcp/tools/service_accounts_test.go | 64 --------- mcp/tools/services.go | 13 +- 30 files changed, 1067 insertions(+), 169 deletions(-) create mode 100644 mcp/tools/resource_types.go create mode 100644 mcp/tools/resource_types_test.go diff --git a/MCP_README.md b/MCP_README.md index 9493ebc..7db3004 100644 --- a/MCP_README.md +++ b/MCP_README.md @@ -36,6 +36,8 @@ This document describes all 106 tools available in the Massdriver MCP server. | `fork_environment` | Forks a new environment from a parent environment in the same project. Requires `parent_id`, `id`, `name`; optional toggles `copy_secrets`, `copy_remote_references`, and `copy_environment_defaults` (all default false) control what carries over. | | `deploy_environment` | Schedules a deployment of every instance in the environment in dependency order. Cancels any in-flight environment deployment and enqueues a fresh provision wave; changes happen asynchronously. | | `decommission_environment` | Schedules a teardown of every instance in the environment in reverse dependency order (the environment shell stays; use `delete_environment` to remove it afterwards). Blocked when decommission protection is enabled. | +| `list_unfulfilled_dependencies` | Lists required dependency inputs across the environment's instances that nothing fills — no link, no remote reference, no environment default. Each entry is one input a deploy would block on. | +| `list_environment_links` | Lists the blueprint links in effect in the environment, given the bundle versions its instances actually run. | ## Instances @@ -91,9 +93,9 @@ This document describes all 106 tools available in the Massdriver MCP server. | Tool | Description | |------|-------------| -| `list_resources` | Lists resources. Optionally filter by `origin`, `resource_type`, `environment_id`, or `search`. | +| `list_resources` | Lists resources. Optionally filter by `origin`, `resource_type` (accepts an `@version` pin), `environment_id`, `search`, `attributes`, or a `created_after`/`created_before` window. | | `get_resource` | Gets a resource by ID (payload values are masked). | -| `create_resource` | Imports a resource. Requires `resource_type_id` and `name`. | +| `create_resource` | Imports a resource. Requires `resource_type_id` and `name`. Use `get_resource_type` first to learn the payload schema. | | `update_resource` | Updates a resource's name or payload. | | `delete_resource` | Deletes an imported resource. | | `export_resource` | Exports a resource with unmasked payload (audit-logged). | @@ -101,11 +103,20 @@ This document describes all 106 tools available in the Massdriver MCP server. | `delete_resource_grant` | Deletes a sharing grant. | | `list_resource_grants` | Lists sharing grants on a resource. | +## Resource Types + +| Tool | Description | +|------|-------------| +| `get_resource_type` | Gets a resource type, including the JSON `schema` a `create_resource` payload must satisfy, import `instructions`, and its `connectionOrientation`. Accepts a bare identifier or an `@version` pin (`aws-iam-role@1.2.3`, `@~1`, `@latest`). | +| `list_resource_type_dependents` | Lists what depends on a resource type within one environment, one entry per (instance, dependency field) pair. | + ## Organization | Tool | Description | |------|-------------| | `get_organization` | Gets the current organization's details (id, name, subscription status, timestamps). Custom attributes and members are not included here — use `list_custom_attributes` and `list_organization_members` for those. | +| `get_organization_settings` | Gets organization-wide behavior settings (`defaultBundleAccess`). Requires `organization:manageSettings`. | +| `update_organization_settings` | Updates organization-wide behavior settings. `default_bundle_access` is `NONE` or `ALL_PROJECTS` and applies only to repositories created afterwards. Requires `organization:manageSettings`. | | `create_custom_attribute` | Creates a custom attribute definition. Requires `key` and `scope`. `required` defaults to false here; setting it true makes the attribute mandatory org-wide at its scope. | | `update_custom_attribute` | Updates a custom attribute's required flag or allowed values. | | `delete_custom_attribute` | Deletes a custom attribute definition. | @@ -151,7 +162,6 @@ This document describes all 106 tools available in the Massdriver MCP server. |------|-------------| | `list_service_accounts` | Lists all service accounts. Optionally filter by `search`. | | `get_service_account` | Gets a service account by ID. | -| `create_service_account` | Creates a service account. Response includes the bearer token (shown once). | | `update_service_account` | Updates a service account's name or description. | | `delete_service_account` | Deletes a service account. | diff --git a/README.md b/README.md index 8a6d240..07c500d 100644 --- a/README.md +++ b/README.md @@ -126,15 +126,15 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) The server uses stdio transport, compatible with any MCP client. -## Available Tools (106) +## Available Tools (111) See [MCP_README.md](MCP_README.md) for the full tool reference, including per-tool descriptions, input conventions, pagination, and behavioral annotations. ### Projects (6) `list_projects` `get_project` `create_project` `clone_project` `update_project` `delete_project` -### Environments (11) -`list_environments` `get_environment` `create_environment` `update_environment` `delete_environment` `set_environment_default` `remove_environment_default` `compare_environments` `fork_environment` `deploy_environment` `decommission_environment` +### Environments (13) +`list_environments` `get_environment` `create_environment` `update_environment` `delete_environment` `set_environment_default` `remove_environment_default` `compare_environments` `fork_environment` `deploy_environment` `decommission_environment` `list_unfulfilled_dependencies` `list_environment_links` ### Instances (10) `list_instances` `get_instance` `update_instance` `set_instance_secret` `remove_instance_secret` `set_remote_reference` `remove_remote_reference` `copy_instance` `orphan_instance` `list_alarms` @@ -151,8 +151,11 @@ See [MCP_README.md](MCP_README.md) for the full tool reference, including per-to ### Resources (9) `list_resources` `get_resource` `create_resource` `update_resource` `delete_resource` `export_resource` `create_resource_grant` `delete_resource_grant` `list_resource_grants` -### Organization (6) -`get_organization` `create_custom_attribute` `update_custom_attribute` `delete_custom_attribute` `list_organization_members` `list_custom_attributes` +### Resource Types (2) +`get_resource_type` `list_resource_type_dependents` + +### Organization (8) +`get_organization` `get_organization_settings` `update_organization_settings` `create_custom_attribute` `update_custom_attribute` `delete_custom_attribute` `list_organization_members` `list_custom_attributes` ### Viewer (1) `get_viewer` @@ -163,8 +166,8 @@ See [MCP_README.md](MCP_README.md) for the full tool reference, including per-to ### Groups (14) `list_groups` `get_group` `create_group` `update_group` `delete_group` `add_group_user` `remove_group_user` `revoke_group_invitation` `add_group_service_account` `remove_group_service_account` `list_group_members` `list_group_service_accounts` `list_group_invitations` `list_group_policies` -### Service Accounts (5) -`list_service_accounts` `get_service_account` `create_service_account` `update_service_account` `delete_service_account` +### Service Accounts (4) +`list_service_accounts` `get_service_account` `update_service_account` `delete_service_account` ### OCI Repos (8) `list_oci_repos` `get_oci_repo` `create_oci_repo` `update_oci_repo` `delete_oci_repo` `create_oci_repo_grant` `delete_oci_repo_grant` `list_oci_repo_grants` diff --git a/go.mod b/go.mod index 05cec6b..8bdcb53 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25 require ( github.com/google/jsonschema-go v0.3.0 - github.com/massdriver-cloud/massdriver-sdk-go v0.2.17 + github.com/massdriver-cloud/massdriver-sdk-go v0.2.19 github.com/modelcontextprotocol/go-sdk v1.0.0 ) diff --git a/go.sum b/go.sum index 8a15ad3..8415985 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/massdriver-cloud/massdriver-sdk-go v0.2.17 h1:Afv/OJWzTwPPD+1r3yErDI/mn3BuKePjFFMrGUXC1fk= github.com/massdriver-cloud/massdriver-sdk-go v0.2.17/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.19 h1:4p9+wexriVdfO6yC2bVEOOSohROXMup0ATx4f0tupVY= +github.com/massdriver-cloud/massdriver-sdk-go v0.2.19/go.mod h1:6NrSP+wfGQvUOAggsz10/Wkln8CKmk3VBnD+OJzZgFY= github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74= github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= diff --git a/main_test.go b/main_test.go index 1dec73f..590f5f8 100644 --- a/main_test.go +++ b/main_test.go @@ -60,6 +60,7 @@ func TestMCPServerTools(t *testing.T) { "list_environments", "get_environment", "create_environment", "update_environment", "delete_environment", "set_environment_default", "remove_environment_default", "compare_environments", "fork_environment", "deploy_environment", "decommission_environment", + "list_unfulfilled_dependencies", "list_environment_links", // Instances "list_instances", "get_instance", "update_instance", "set_instance_secret", "remove_instance_secret", "set_remote_reference", "remove_remote_reference", "copy_instance", "orphan_instance", "list_alarms", @@ -74,8 +75,11 @@ func TestMCPServerTools(t *testing.T) { // Resources "list_resources", "get_resource", "create_resource", "update_resource", "delete_resource", "export_resource", "create_resource_grant", "delete_resource_grant", "list_resource_grants", + // Resource Types + "get_resource_type", "list_resource_type_dependents", // Organization - "get_organization", "create_custom_attribute", "update_custom_attribute", "delete_custom_attribute", + "get_organization", "get_organization_settings", "update_organization_settings", + "create_custom_attribute", "update_custom_attribute", "delete_custom_attribute", "list_organization_members", "list_custom_attributes", // Viewer "get_viewer", @@ -87,7 +91,7 @@ func TestMCPServerTools(t *testing.T) { "add_group_service_account", "remove_group_service_account", "list_group_members", "list_group_service_accounts", "list_group_invitations", "list_group_policies", // Service Accounts - "list_service_accounts", "get_service_account", "create_service_account", "update_service_account", "delete_service_account", + "list_service_accounts", "get_service_account", "update_service_account", "delete_service_account", // OCI Repos "list_oci_repos", "get_oci_repo", "create_oci_repo", "update_oci_repo", "delete_oci_repo", "create_oci_repo_grant", "delete_oci_repo_grant", "list_oci_repo_grants", diff --git a/mcp/server.go b/mcp/server.go index b590a16..7bc92b2 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -59,6 +59,7 @@ func clientFromSDK(client *massdriver.Client) *tools.Client { Components: client.Components, Bundles: client.Bundles, Resources: client.Resources, + ResourceTypes: client.ResourceTypes, Organizations: client.Organizations, Viewer: client.Viewer, AuditLogs: client.AuditLogs, @@ -85,6 +86,8 @@ func (s *Server) registerTools() { // Environments mcpsdk.AddTool(s.mcpServer, tools.ListEnvironmentsTool, tools.HandleListEnvironments(c)) mcpsdk.AddTool(s.mcpServer, tools.GetEnvironmentTool, tools.HandleGetEnvironment(c)) + mcpsdk.AddTool(s.mcpServer, tools.ListUnfulfilledDependenciesTool, tools.HandleListUnfulfilledDependencies(c)) + mcpsdk.AddTool(s.mcpServer, tools.ListEnvironmentLinksTool, tools.HandleListEnvironmentLinks(c)) mcpsdk.AddTool(s.mcpServer, tools.CreateEnvironmentTool, tools.HandleCreateEnvironment(c)) mcpsdk.AddTool(s.mcpServer, tools.UpdateEnvironmentTool, tools.HandleUpdateEnvironment(c)) mcpsdk.AddTool(s.mcpServer, tools.DeleteEnvironmentTool, tools.HandleDeleteEnvironment(c)) @@ -144,8 +147,14 @@ func (s *Server) registerTools() { mcpsdk.AddTool(s.mcpServer, tools.DeleteResourceGrantTool, tools.HandleDeleteResourceGrant(c)) mcpsdk.AddTool(s.mcpServer, tools.ListResourceGrantsTool, tools.HandleListResourceGrants(c)) + // Resource Types + mcpsdk.AddTool(s.mcpServer, tools.GetResourceTypeTool, tools.HandleGetResourceType(c)) + mcpsdk.AddTool(s.mcpServer, tools.ListResourceTypeDependentsTool, tools.HandleListResourceTypeDependents(c)) + // Organization mcpsdk.AddTool(s.mcpServer, tools.GetOrganizationTool, tools.HandleGetOrganization(c)) + mcpsdk.AddTool(s.mcpServer, tools.GetOrganizationSettingsTool, tools.HandleGetOrganizationSettings(c)) + mcpsdk.AddTool(s.mcpServer, tools.UpdateOrganizationSettingsTool, tools.HandleUpdateOrganizationSettings(c)) mcpsdk.AddTool(s.mcpServer, tools.CreateCustomAttributeTool, tools.HandleCreateCustomAttribute(c)) mcpsdk.AddTool(s.mcpServer, tools.UpdateCustomAttributeTool, tools.HandleUpdateCustomAttribute(c)) mcpsdk.AddTool(s.mcpServer, tools.DeleteCustomAttributeTool, tools.HandleDeleteCustomAttribute(c)) @@ -179,7 +188,6 @@ func (s *Server) registerTools() { // Service Accounts mcpsdk.AddTool(s.mcpServer, tools.ListServiceAccountsTool, tools.HandleListServiceAccounts(c)) mcpsdk.AddTool(s.mcpServer, tools.GetServiceAccountTool, tools.HandleGetServiceAccount(c)) - mcpsdk.AddTool(s.mcpServer, tools.CreateServiceAccountTool, tools.HandleCreateServiceAccount(c)) mcpsdk.AddTool(s.mcpServer, tools.UpdateServiceAccountTool, tools.HandleUpdateServiceAccount(c)) mcpsdk.AddTool(s.mcpServer, tools.DeleteServiceAccountTool, tools.HandleDeleteServiceAccount(c)) diff --git a/mcp/structured_content_test.go b/mcp/structured_content_test.go index 5333db9..f33ffd4 100644 --- a/mcp/structured_content_test.go +++ b/mcp/structured_content_test.go @@ -19,6 +19,7 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/policies" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/projects" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resources" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resourcetypes" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/server" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/serviceaccounts" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" @@ -159,6 +160,7 @@ func stubToolsClient() *tools.Client { Components: scComponents{}, Bundles: scBundles{}, Resources: scResources{}, + ResourceTypes: scResourceTypes{}, Organizations: scOrganizations{}, Viewer: scViewer{}, AuditLogs: scAuditLogs{}, @@ -200,6 +202,12 @@ func (scEnvironments) ListPage(context.Context, environments.ListInput) (types.P func (scEnvironments) Get(context.Context, string) (*environments.Environment, error) { return &environments.Environment{}, nil } +func (scEnvironments) Links(context.Context, string) ([]types.Link, error) { + return []types.Link{{}}, nil +} +func (scEnvironments) UnfulfilledDependencies(context.Context, string) ([]environments.UnfulfilledDependency, error) { + return []environments.UnfulfilledDependency{{}}, nil +} func (scEnvironments) Create(context.Context, string, environments.CreateInput) (*environments.Environment, error) { return &environments.Environment{}, nil } @@ -355,11 +363,26 @@ func (scResources) ListGrantsPage(context.Context, string, resources.ListGrantsI return types.Page[resources.Grant]{Items: []resources.Grant{{}}}, nil } +type scResourceTypes struct{} + +func (scResourceTypes) Get(context.Context, string) (*resourcetypes.ResourceType, error) { + return &resourcetypes.ResourceType{}, nil +} +func (scResourceTypes) Dependents(context.Context, string, string) ([]resourcetypes.Dependent, error) { + return []resourcetypes.Dependent{{}}, nil +} + type scOrganizations struct{} func (scOrganizations) Get(context.Context) (*organizations.Organization, error) { return &organizations.Organization{}, nil } +func (scOrganizations) GetSettings(context.Context) (*organizations.Settings, error) { + return &organizations.Settings{}, nil +} +func (scOrganizations) UpdateSettings(context.Context, organizations.UpdateSettingsInput) (*organizations.Settings, error) { + return &organizations.Settings{}, nil +} func (scOrganizations) CreateCustomAttribute(context.Context, organizations.CreateCustomAttributeInput) (*organizations.CustomAttribute, error) { return &organizations.CustomAttribute{}, nil } @@ -433,9 +456,6 @@ func (scServiceAccounts) ListPage(context.Context, serviceaccounts.ListInput) (t func (scServiceAccounts) Get(context.Context, string) (*serviceaccounts.ServiceAccount, error) { return &serviceaccounts.ServiceAccount{}, nil } -func (scServiceAccounts) Create(context.Context, serviceaccounts.CreateInput) (*serviceaccounts.Created, error) { - return &serviceaccounts.Created{}, nil -} func (scServiceAccounts) Update(context.Context, string, serviceaccounts.UpdateInput) (*serviceaccounts.ServiceAccount, error) { return &serviceaccounts.ServiceAccount{}, nil } diff --git a/mcp/tools/components.go b/mcp/tools/components.go index 0b7c03b..d48efd3 100644 --- a/mcp/tools/components.go +++ b/mcp/tools/components.go @@ -38,8 +38,11 @@ func HandleListComponents(c *Client) func(context.Context, *mcpsdk.CallToolReque } var GetComponentTool = &mcpsdk.Tool{ - Name: "get_component", - Description: "Gets a specific component by ID, including its configuration and links.", + Name: "get_component", + Description: "Gets a specific component by ID, including its configuration and links. Each link carries " + + "`fromVersionConstraint` and `toVersionConstraint` — the tilde version ranges of the components it routes between " + + "(`~1` covers 1.x, `~0.4` covers 0.4.x) — which decide the environments the link actually applies in. " + + "Use list_environment_links to see which links are in effect for a given environment.", } type GetComponentInput struct { diff --git a/mcp/tools/environments.go b/mcp/tools/environments.go index ca7d3b3..16f4507 100644 --- a/mcp/tools/environments.go +++ b/mcp/tools/environments.go @@ -71,6 +71,72 @@ func HandleGetEnvironment(c *Client) func(context.Context, *mcpsdk.CallToolReque } } +var ListUnfulfilledDependenciesTool = &mcpsdk.Tool{ + Name: "list_unfulfilled_dependencies", + Description: "Lists the required dependency inputs across an environment's instances that nothing fills — no blueprint link, " + + "no per-instance remote reference, and no environment default of the matching resource type. " + + "Each entry is one input a deploy would block on, naming the instance, the input field, and the resource type it needs; " + + "optional inputs are never included, so an empty list means nothing is blocking on connections. " + + "Use this first when deploy_environment or a deployment fails to start. " + + "Fix each entry by wiring the slot with link_components (a blueprint link), set_remote_reference (a resource wired into one instance), " + + "or set_environment_default (a resource shared to every instance in the environment). " + + "Results are sorted by instance identifier, then input name. The instance and resource type are slim references (id and name only).", +} + +type ListUnfulfilledDependenciesInput struct { + EnvironmentID string `json:"environment_id" jsonschema:"The environment identifier to check (e.g., 'myproj-staging')."` +} + +func HandleListUnfulfilledDependencies(c *Client) func(context.Context, *mcpsdk.CallToolRequest, ListUnfulfilledDependenciesInput) (*mcpsdk.CallToolResult, any, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args ListUnfulfilledDependenciesInput) (*mcpsdk.CallToolResult, any, error) { + if args.EnvironmentID == "" { + return nil, nil, fmt.Errorf("list_unfulfilled_dependencies: environment_id is required") + } + + deps, err := c.Environments.UnfulfilledDependencies(ctx, args.EnvironmentID) + if err != nil { + return nil, nil, fmt.Errorf("list_unfulfilled_dependencies: %w", err) + } + + out := listResult(deps) + return jsonResultStripping(out, "icon") + } +} + +var ListEnvironmentLinksTool = &mcpsdk.Tool{ + Name: "list_environment_links", + Description: "Lists the blueprint links actually in effect in an environment, given the bundle versions its instances run. " + + "A project's blueprint lists every link in the architecture; this is the subset that applies here, because a component can run " + + "different versions in different environments and a link applies only where the versions at both ends fall inside its version range " + + "(`fromVersionConstraint` / `toVersionConstraint`, tilde constraints where `~1` covers 1.x and `~0.4` covers 0.4.x). " + + "A link whose source or destination has no instance in this environment does not appear. " + + "Use list_components for the project-wide blueprint instead.", +} + +type ListEnvironmentLinksInput struct { + EnvironmentID string `json:"environment_id" jsonschema:"The environment identifier whose effective links to list (e.g., 'myproj-staging')."` +} + +func HandleListEnvironmentLinks(c *Client) func(context.Context, *mcpsdk.CallToolRequest, ListEnvironmentLinksInput) (*mcpsdk.CallToolResult, any, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args ListEnvironmentLinksInput) (*mcpsdk.CallToolResult, any, error) { + if args.EnvironmentID == "" { + return nil, nil, fmt.Errorf("list_environment_links: environment_id is required") + } + + links, err := c.Environments.Links(ctx, args.EnvironmentID) + if err != nil { + return nil, nil, fmt.Errorf("list_environment_links: %w", err) + } + + out := listResult(links) + result, err := jsonResult(out) + if err != nil { + return nil, nil, err + } + return result, out, nil + } +} + var CreateEnvironmentTool = &mcpsdk.Tool{ Name: "create_environment", Description: "Creates a new environment within a project.", diff --git a/mcp/tools/environments_test.go b/mcp/tools/environments_test.go index 17a26b5..d9bbe59 100644 --- a/mcp/tools/environments_test.go +++ b/mcp/tools/environments_test.go @@ -7,12 +7,15 @@ import ( "testing" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/environments" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/instances" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" ) type stubEnvironments struct { listPageFn func(context.Context, environments.ListInput) (types.Page[environments.Environment], error) getFn func(context.Context, string) (*environments.Environment, error) + linksFn func(context.Context, string) ([]types.Link, error) + unfulfilledFn func(context.Context, string) ([]environments.UnfulfilledDependency, error) createFn func(context.Context, string, environments.CreateInput) (*environments.Environment, error) updateFn func(context.Context, string, environments.UpdateInput) (*environments.Environment, error) deleteFn func(context.Context, string) (*environments.Environment, error) @@ -30,6 +33,12 @@ func (s *stubEnvironments) ListPage(ctx context.Context, input environments.List func (s *stubEnvironments) Get(ctx context.Context, id string) (*environments.Environment, error) { return s.getFn(ctx, id) } +func (s *stubEnvironments) Links(ctx context.Context, id string) ([]types.Link, error) { + return s.linksFn(ctx, id) +} +func (s *stubEnvironments) UnfulfilledDependencies(ctx context.Context, id string) ([]environments.UnfulfilledDependency, error) { + return s.unfulfilledFn(ctx, id) +} func (s *stubEnvironments) Create(ctx context.Context, projectID string, input environments.CreateInput) (*environments.Environment, error) { return s.createFn(ctx, projectID, input) } @@ -718,3 +727,133 @@ func TestHandleDecommissionEnvironment(t *testing.T) { }) } } + +func TestHandleListUnfulfilledDependencies(t *testing.T) { + tests := []struct { + name string + input ListUnfulfilledDependenciesInput + stub *stubEnvironments + wantErr string + wantText string + }{ + { + name: "missing environment_id", + input: ListUnfulfilledDependenciesInput{}, + stub: &stubEnvironments{}, + wantErr: "environment_id is required", + }, + { + name: "reports the instance, field, and resource type needed", + input: ListUnfulfilledDependenciesInput{EnvironmentID: "myproj-staging"}, + stub: &stubEnvironments{ + unfulfilledFn: func(context.Context, string) ([]environments.UnfulfilledDependency, error) { + return []environments.UnfulfilledDependency{{ + Instance: instances.Instance{ID: "myproj-staging-api"}, + Field: "database", + ResourceType: types.ResourceType{ID: "aws-rds-instance"}, + }}, nil + }, + }, + wantText: "aws-rds-instance", + }, + { + name: "nothing blocking yields an empty array, not null", + input: ListUnfulfilledDependenciesInput{EnvironmentID: "myproj-staging"}, + stub: &stubEnvironments{ + unfulfilledFn: func(context.Context, string) ([]environments.UnfulfilledDependency, error) { + return nil, nil + }, + }, + wantText: "\"items\": []", + }, + { + name: "propagates lookup failure", + input: ListUnfulfilledDependenciesInput{EnvironmentID: "nope"}, + stub: &stubEnvironments{ + unfulfilledFn: func(context.Context, string) ([]environments.UnfulfilledDependency, error) { + return nil, errors.New("not found") + }, + }, + wantErr: "list_unfulfilled_dependencies", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{Environments: tt.stub} + result, _, err := HandleListUnfulfilledDependencies(c)(context.Background(), nil, tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := resultText(t, result); !strings.Contains(got, tt.wantText) { + t.Errorf("expected %q in result, got: %s", tt.wantText, got) + } + }) + } +} + +func TestHandleListEnvironmentLinks(t *testing.T) { + tests := []struct { + name string + input ListEnvironmentLinksInput + stub *stubEnvironments + wantErr string + wantText string + }{ + { + name: "missing environment_id", + input: ListEnvironmentLinksInput{}, + stub: &stubEnvironments{}, + wantErr: "environment_id is required", + }, + { + name: "surfaces the version constraints that scope each link", + input: ListEnvironmentLinksInput{EnvironmentID: "myproj-staging"}, + stub: &stubEnvironments{ + linksFn: func(context.Context, string) ([]types.Link, error) { + return []types.Link{{ + ID: "link1", + FromField: "network", + ToField: "vpc", + FromVersionConstraint: "~1", + ToVersionConstraint: "~0.4", + }}, nil + }, + }, + wantText: "fromVersionConstraint", + }, + { + name: "no applicable links yields an empty array, not null", + input: ListEnvironmentLinksInput{EnvironmentID: "myproj-staging"}, + stub: &stubEnvironments{ + linksFn: func(context.Context, string) ([]types.Link, error) { return nil, nil }, + }, + wantText: "\"items\": []", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{Environments: tt.stub} + result, _, err := HandleListEnvironmentLinks(c)(context.Background(), nil, tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := resultText(t, result); !strings.Contains(got, tt.wantText) { + t.Errorf("expected %q in result, got: %s", tt.wantText, got) + } + }) + } +} diff --git a/mcp/tools/helpers.go b/mcp/tools/helpers.go index eb9f375..d104ebb 100644 --- a/mcp/tools/helpers.go +++ b/mcp/tools/helpers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" @@ -65,6 +66,43 @@ func listResult[T any](items []T) ListResult[T] { return ListResult[T]{Items: items} } +// AttributeFilterInput is the tool-facing shape of a single custom-attribute +// filter. One entry targets one attribute key; when a List tool takes several, +// they are AND'd together. Eq and In are alternatives — set one of them. +type AttributeFilterInput struct { + Key string `json:"key" jsonschema:"The custom attribute key to match (e.g. 'team'). Use list_custom_attributes to discover the keys defined for the organization."` + Eq string `json:"eq,omitempty" jsonschema:"Optional. Matches when the value for key equals this string exactly. Use either eq or in, not both."` + In []string `json:"in,omitempty" jsonschema:"Optional. Matches when the value for key is any of these strings. Use either eq or in, not both."` +} + +// toAttributeFilters maps the tool-facing attribute filters onto the SDK shape, +// returning nil for an empty input so callers can pass it through unconditionally. +func toAttributeFilters(in []AttributeFilterInput) []types.AttributeFilter { + if len(in) == 0 { + return nil + } + out := make([]types.AttributeFilter, 0, len(in)) + for _, a := range in { + out = append(out, types.AttributeFilter{Key: a.Key, Eq: a.Eq, In: a.In}) + } + return out +} + +// parseTimestamp parses an optional RFC 3339 timestamp argument, returning the +// zero time when the argument is empty so the SDK leaves that bound open. The +// error names the tool and field so a malformed value is actionable rather than +// silently dropping the filter. +func parseTimestamp(tool, field, value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, fmt.Errorf("%s: %s must be an RFC 3339 timestamp (e.g. 2026-01-15T00:00:00Z): %w", tool, field, err) + } + return t, nil +} + // textResult builds a CallToolResult with a single text content item. func textResult(text string) *mcpsdk.CallToolResult { return &mcpsdk.CallToolResult{ diff --git a/mcp/tools/helpers_test.go b/mcp/tools/helpers_test.go index 08c9cea..27074b1 100644 --- a/mcp/tools/helpers_test.go +++ b/mcp/tools/helpers_test.go @@ -1,7 +1,9 @@ package tools import ( + "strings" "testing" + "time" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/gql" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -44,3 +46,57 @@ func mutationFailedErr(op, field, msg string) error { {Code: "invalid", Field: field, Message: msg}, }) } + +func TestToAttributeFilters(t *testing.T) { + if got := toAttributeFilters(nil); got != nil { + t.Errorf("empty input should map to nil so the filter stays off the wire, got %v", got) + } + + got := toAttributeFilters([]AttributeFilterInput{ + {Key: "team", Eq: "platform"}, + {Key: "cost_center", In: []string{"a", "b"}}, + }) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Key != "team" || got[0].Eq != "platform" { + t.Errorf("got[0] = %+v, want key=team eq=platform", got[0]) + } + if got[1].Key != "cost_center" || len(got[1].In) != 2 { + t.Errorf("got[1] = %+v, want key=cost_center with 2 values", got[1]) + } +} + +func TestParseTimestamp(t *testing.T) { + t.Run("empty stays zero so the bound is open", func(t *testing.T) { + got, err := parseTimestamp("list_resources", "created_after", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.IsZero() { + t.Errorf("got %v, want the zero time", got) + } + }) + + t.Run("parses RFC 3339", func(t *testing.T) { + got, err := parseTimestamp("list_resources", "created_after", "2026-01-15T00:00:00Z") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Year() != 2026 || got.Month() != time.January || got.Day() != 15 { + t.Errorf("got %v, want 2026-01-15", got) + } + }) + + t.Run("rejects a malformed value rather than dropping the filter", func(t *testing.T) { + _, err := parseTimestamp("list_resources", "created_after", "yesterday") + if err == nil { + t.Fatal("expected an error, got nil") + } + for _, want := range []string{"list_resources", "created_after", "RFC 3339"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention %q", err, want) + } + } + }) +} diff --git a/mcp/tools/instances.go b/mcp/tools/instances.go index 28b3261..0f5e2d0 100644 --- a/mcp/tools/instances.go +++ b/mcp/tools/instances.go @@ -11,18 +11,19 @@ import ( var ListInstancesTool = &mcpsdk.Tool{ Name: "list_instances", Description: "Lists instances in the organization, one page at a time. " + - "STRONGLY PREFER filtering by `project_id`, `environment_id`, or `status` — unfiltered lists can span thousands of instances. " + + "STRONGLY PREFER filtering by `project_id`, `environment_id`, `status`, or `attributes` — unfiltered lists can span thousands of instances. " + "Returns up to `page_size` instances (default 25, max 100) plus a `next_cursor` for the following page. " + "To continue, call again with `cursor` set to the previous `next_cursor`. " + "Do NOT paginate to exhaustion unless the user explicitly asked for every instance.", } type ListInstancesInput struct { - ProjectID string `json:"project_id,omitempty" jsonschema:"Optional. Filter to instances belonging to this project ID."` - EnvironmentID string `json:"environment_id,omitempty" jsonschema:"Optional. Filter to instances belonging to this environment ID."` - Status string `json:"status,omitempty" jsonschema:"Optional. Filter by status: INITIALIZED, PROVISIONED, DECOMMISSIONED, or FAILED."` - Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` - PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` + ProjectID string `json:"project_id,omitempty" jsonschema:"Optional. Filter to instances belonging to this project ID."` + EnvironmentID string `json:"environment_id,omitempty" jsonschema:"Optional. Filter to instances belonging to this environment ID."` + Status string `json:"status,omitempty" jsonschema:"Optional. Filter by status: INITIALIZED, PROVISIONED, DECOMMISSIONED, or FAILED."` + Attributes []AttributeFilterInput `json:"attributes,omitempty" jsonschema:"Optional. Filter by custom attributes. An instance matches attributes set anywhere on its chain (project, environment, component, instance). Multiple entries are AND'd together."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` + PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` } func HandleListInstances(c *Client) func(context.Context, *mcpsdk.CallToolRequest, ListInstancesInput) (*mcpsdk.CallToolResult, any, error) { @@ -31,6 +32,7 @@ func HandleListInstances(c *Client) func(context.Context, *mcpsdk.CallToolReques ProjectID: args.ProjectID, EnvironmentID: args.EnvironmentID, Status: instances.Status(args.Status), + Attributes: toAttributeFilters(args.Attributes), PageSize: clampPageSize(args.PageSize), After: args.Cursor, }) diff --git a/mcp/tools/instances_test.go b/mcp/tools/instances_test.go index faa3953..17a7297 100644 --- a/mcp/tools/instances_test.go +++ b/mcp/tools/instances_test.go @@ -698,3 +698,24 @@ func TestHandleOrphanInstance(t *testing.T) { }) } } + +// TestListInstancesForwardsAttributes verifies the attribute filter reaches the SDK. +func TestListInstancesForwardsAttributes(t *testing.T) { + var got instances.ListInput + c := &Client{Instances: &stubInstances{ + listPageFn: func(_ context.Context, input instances.ListInput) (types.Page[instances.Instance], error) { + got = input + return types.Page[instances.Instance]{}, nil + }, + }} + + _, _, err := HandleListInstances(c)(context.Background(), nil, ListInstancesInput{ + Attributes: []AttributeFilterInput{{Key: "team", Eq: "platform"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.Attributes) != 1 || got.Attributes[0].Eq != "platform" { + t.Errorf("Attributes = %+v, want one entry with eq=platform", got.Attributes) + } +} diff --git a/mcp/tools/metadata.go b/mcp/tools/metadata.go index 58c830c..5c34922 100644 --- a/mcp/tools/metadata.go +++ b/mcp/tools/metadata.go @@ -67,14 +67,15 @@ func annotate(tools []*mcpsdk.Tool, annotations func() *mcpsdk.ToolAnnotations) func applyAnnotations() { readers := []*mcpsdk.Tool{ GetProjectTool, ListProjectsTool, - GetEnvironmentTool, ListEnvironmentsTool, + GetEnvironmentTool, ListEnvironmentsTool, ListUnfulfilledDependenciesTool, ListEnvironmentLinksTool, GetInstanceTool, ListInstancesTool, ListAlarmsTool, GetDeploymentTool, ListDeploymentsTool, GetDeploymentLogsTool, CompareDeploymentsTool, CompareEnvironmentsTool, GetComponentTool, ListComponentsTool, GetBundleTool, GetResourceTool, ListResourcesTool, ExportResourceTool, ListResourceGrantsTool, - GetOrganizationTool, + GetResourceTypeTool, ListResourceTypeDependentsTool, + GetOrganizationTool, GetOrganizationSettingsTool, GetViewerTool, GetAuditLogTool, ListAuditLogsTool, ListAuditLogEventTypesTool, GetGroupTool, ListGroupsTool, @@ -94,7 +95,7 @@ func applyAnnotations() { additive := []*mcpsdk.Tool{ CreateProjectTool, CloneProjectTool, CreateEnvironmentTool, ForkEnvironmentTool, AddComponentTool, LinkComponentsTool, CreateResourceTool, CreateResourceGrantTool, CreateOciRepoGrantTool, CreateCustomAttributeTool, - CreateGroupTool, CreateServiceAccountTool, CreateOciRepoTool, CreatePolicyTool, + CreateGroupTool, CreateOciRepoTool, CreatePolicyTool, ProposeDeploymentTool, RejectDeploymentTool, PlanDeploymentTool, RollbackDeploymentTool, } annotate(additive, func() *mcpsdk.ToolAnnotations { return writeHints(false, false) }) @@ -107,6 +108,7 @@ func applyAnnotations() { UpdateComponentTool, SetComponentPositionTool, UpdateResourceTool, UpdateCustomAttributeTool, UpdateGroupTool, AddGroupUserTool, AddGroupServiceAccountTool, UpdateServiceAccountTool, UpdateOciRepoTool, UpdatePolicyTool, + UpdateOrganizationSettingsTool, } annotate(updates, func() *mcpsdk.ToolAnnotations { return writeHints(false, true) }) @@ -151,6 +153,7 @@ func applyEnums() { withEnums(ListResourcesTool, ListResourcesInput{}, map[string][]string{"origin": {"IMPORTED", "PROVISIONED"}}) withEnums(CreateResourceGrantTool, CreateResourceGrantInput{}, map[string][]string{"action": {"resource:export"}}) withEnums(CreateCustomAttributeTool, CreateCustomAttributeInput{}, map[string][]string{"scope": scopes}) + withEnums(UpdateOrganizationSettingsTool, UpdateOrganizationSettingsInput{}, map[string][]string{"default_bundle_access": {"NONE", "ALL_PROJECTS"}}) withEnums(ListPolicyAttributeValuesTool, ListPolicyAttributeValuesInput{}, map[string][]string{"scope": scopes}) withEnums(CreatePolicyTool, CreatePolicyInput{}, map[string][]string{"effect": effects}) withEnums(UpdatePolicyTool, UpdatePolicyInput{}, map[string][]string{"effect": effects}) @@ -165,11 +168,12 @@ func applyEnums() { // are stored in 255-character columns; creation-time identifier slugs are // capped at 20 characters. Only slugs chosen at creation are constrained — the // `id` on get/update tools is a lookup reference (e.g. 'myproj-staging') that -// can exceed the slug limit. +// can exceed the slug limit. OCI repository names have their own, longer limit. func applyMaxLengths() { const ( descriptionMax = 255 identifierMax = 20 + ociRepoNameMax = 100 ) withMaxLengths(CreateProjectTool, CreateProjectInput{}, map[string]int{"description": descriptionMax, "id": identifierMax}) withMaxLengths(CloneProjectTool, CloneProjectInput{}, map[string]int{"description": descriptionMax, "id": identifierMax}) @@ -179,6 +183,7 @@ func applyMaxLengths() { withMaxLengths(UpdateEnvironmentTool, UpdateEnvironmentInput{}, map[string]int{"description": descriptionMax}) withMaxLengths(AddComponentTool, AddComponentInput{}, map[string]int{"description": descriptionMax, "id": identifierMax}) withMaxLengths(UpdateComponentTool, UpdateComponentInput{}, map[string]int{"description": descriptionMax}) + withMaxLengths(CreateOciRepoTool, CreateOciRepoInput{}, map[string]int{"id": ociRepoNameMax}) } // toolSchema returns the tool's input schema for constraint editing: the one diff --git a/mcp/tools/metadata_test.go b/mcp/tools/metadata_test.go index e06d540..737d2bf 100644 --- a/mcp/tools/metadata_test.go +++ b/mcp/tools/metadata_test.go @@ -36,8 +36,8 @@ func TestMutationFailureSetsIsError(t *testing.T) { // registered tool. The list mirrors mcp/server.go's registration. func TestEveryToolHasAnnotations(t *testing.T) { tools := registeredTools() - if len(tools) != 87 { - t.Fatalf("registeredTools lists %d tools, want 87 (keep in sync with registerTools)", len(tools)) + if len(tools) != 92 { + t.Fatalf("registeredTools lists %d tools, want 92 (keep in sync with registerTools)", len(tools)) } seen := make(map[string]bool, len(tools)) for _, tool := range tools { @@ -204,16 +204,18 @@ func registeredTools() []*mcpsdk.Tool { return []*mcpsdk.Tool{ ListProjectsTool, GetProjectTool, CreateProjectTool, UpdateProjectTool, DeleteProjectTool, ListEnvironmentsTool, GetEnvironmentTool, CreateEnvironmentTool, UpdateEnvironmentTool, DeleteEnvironmentTool, SetEnvironmentDefaultTool, RemoveEnvironmentDefaultTool, + ListUnfulfilledDependenciesTool, ListEnvironmentLinksTool, ListInstancesTool, GetInstanceTool, UpdateInstanceTool, SetInstanceSecretTool, RemoveInstanceSecretTool, ListAlarmsTool, ListDeploymentsTool, GetDeploymentTool, GetDeploymentLogsTool, CreateDeploymentTool, ProposeDeploymentTool, ApproveDeploymentTool, RejectDeploymentTool, AbortDeploymentTool, ListComponentsTool, GetComponentTool, AddComponentTool, UpdateComponentTool, RemoveComponentTool, LinkComponentsTool, UnlinkComponentsTool, GetBundleTool, ListResourcesTool, GetResourceTool, CreateResourceTool, UpdateResourceTool, DeleteResourceTool, ExportResourceTool, CreateResourceGrantTool, DeleteResourceGrantTool, ListResourceGrantsTool, - GetOrganizationTool, CreateCustomAttributeTool, UpdateCustomAttributeTool, DeleteCustomAttributeTool, + GetResourceTypeTool, ListResourceTypeDependentsTool, + GetOrganizationTool, GetOrganizationSettingsTool, UpdateOrganizationSettingsTool, CreateCustomAttributeTool, UpdateCustomAttributeTool, DeleteCustomAttributeTool, GetViewerTool, GetAuditLogTool, ListAuditLogsTool, ListAuditLogEventTypesTool, ListGroupsTool, GetGroupTool, CreateGroupTool, UpdateGroupTool, DeleteGroupTool, AddGroupUserTool, RemoveGroupUserTool, RevokeGroupInvitationTool, AddGroupServiceAccountTool, RemoveGroupServiceAccountTool, - ListServiceAccountsTool, GetServiceAccountTool, CreateServiceAccountTool, UpdateServiceAccountTool, DeleteServiceAccountTool, + ListServiceAccountsTool, GetServiceAccountTool, UpdateServiceAccountTool, DeleteServiceAccountTool, ListOciReposTool, GetOciRepoTool, CreateOciRepoTool, UpdateOciRepoTool, DeleteOciRepoTool, CreateOciRepoGrantTool, DeleteOciRepoGrantTool, ListOciRepoGrantsTool, GetPolicyTool, CreatePolicyTool, UpdatePolicyTool, DeletePolicyTool, ListPolicyActionsTool, ListPolicyEntitiesTool, EvaluatePolicyTool, EvaluatePoliciesBatchTool, ExplainPolicyTool, GetPolicyAttributeSchemaTool, ListPolicyAttributeValuesTool, GetServerTool, diff --git a/mcp/tools/oci_repos.go b/mcp/tools/oci_repos.go index 8266f22..176e5f1 100644 --- a/mcp/tools/oci_repos.go +++ b/mcp/tools/oci_repos.go @@ -12,27 +12,42 @@ import ( var ListOciReposTool = &mcpsdk.Tool{ Name: "list_oci_repos", Description: "Lists OCI repositories in the organization, one page at a time. " + - "Filter by `artifact_type` to list repositories of a given type (e.g. 'BUNDLE'). " + - "PREFER filtering by `search` or `artifact_type` to focus the catalog. " + + "Filter by `artifact_type` to list repositories of a given type (e.g. 'BUNDLE'); leaving it empty returns both bundles and resource types. " + + "PREFER filtering by `search`, `artifact_type`, or `attributes` to focus the catalog. " + "Returns up to `page_size` repositories (default 25, max 100) plus a `next_cursor` for the following page. " + "To continue, call again with `cursor` set to the previous `next_cursor`. " + "Do NOT paginate to exhaustion unless the user explicitly asked for every repository.", } type ListOciReposInput struct { - Search string `json:"search,omitempty" jsonschema:"Optional. Search term to filter repositories."` - ArtifactType string `json:"artifact_type,omitempty" jsonschema:"Optional. Filter by artifact type (e.g., 'BUNDLE')."` - Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` - PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` + Search string `json:"search,omitempty" jsonschema:"Optional. Search term to filter repositories."` + ArtifactType string `json:"artifact_type,omitempty" jsonschema:"Optional. Filter by artifact type (e.g., 'BUNDLE'). Omit to list both bundles and resource types."` + Attributes []AttributeFilterInput `json:"attributes,omitempty" jsonschema:"Optional. Filter by repository attributes. Repositories are organization-level, so there is no inheritance — only attributes set on the repository itself match, plus md-repo and md-id, which both resolve to the repository name. Multiple entries are AND'd together."` + CreatedAfter string `json:"created_after,omitempty" jsonschema:"Optional. Only repositories created at or after this instant, as an RFC 3339 timestamp (e.g. '2026-01-15T00:00:00Z'). Bounds are inclusive; omit to leave this side open."` + CreatedBefore string `json:"created_before,omitempty" jsonschema:"Optional. Only repositories created at or before this instant, as an RFC 3339 timestamp (e.g. '2026-01-15T00:00:00Z'). Bounds are inclusive; omit to leave this side open."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` + PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` } func HandleListOciRepos(c *Client) func(context.Context, *mcpsdk.CallToolRequest, ListOciReposInput) (*mcpsdk.CallToolResult, any, error) { return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args ListOciReposInput) (*mcpsdk.CallToolResult, any, error) { + createdAfter, err := parseTimestamp("list_oci_repos", "created_after", args.CreatedAfter) + if err != nil { + return nil, nil, err + } + createdBefore, err := parseTimestamp("list_oci_repos", "created_before", args.CreatedBefore) + if err != nil { + return nil, nil, err + } + page, err := c.OciRepos.ListPage(ctx, ocirepos.ListInput{ - Search: args.Search, - ArtifactType: ocirepos.ArtifactType(args.ArtifactType), - PageSize: clampPageSize(args.PageSize), - After: args.Cursor, + Search: args.Search, + ArtifactType: ocirepos.ArtifactType(args.ArtifactType), + Attributes: toAttributeFilters(args.Attributes), + CreatedAfter: createdAfter, + CreatedBefore: createdBefore, + PageSize: clampPageSize(args.PageSize), + After: args.Cursor, }) if err != nil { return nil, nil, fmt.Errorf("list_oci_repos: %w", err) @@ -73,7 +88,7 @@ var CreateOciRepoTool = &mcpsdk.Tool{ } type CreateOciRepoInput struct { - ID string `json:"id" jsonschema:"Repository name (immutable after creation)."` + ID string `json:"id" jsonschema:"Repository name (immutable after creation). Lowercase letters, numbers, dashes, and underscores; max 100 characters."` ArtifactType string `json:"artifact_type" jsonschema:"Artifact type (e.g., 'BUNDLE')."` Attributes map[string]any `json:"attributes,omitempty" jsonschema:"Optional. Custom attributes for the repository."` } diff --git a/mcp/tools/oci_repos_test.go b/mcp/tools/oci_repos_test.go index 6c71378..627fa72 100644 --- a/mcp/tools/oci_repos_test.go +++ b/mcp/tools/oci_repos_test.go @@ -433,3 +433,31 @@ func TestHandleListOciRepoGrants(t *testing.T) { }) } } + +// TestListOciReposForwardsFilters verifies the newer filter arguments reach the SDK. +func TestListOciReposForwardsFilters(t *testing.T) { + var got ocirepos.ListInput + c := &Client{OciRepos: &stubOciRepos{ + listPageFn: func(_ context.Context, input ocirepos.ListInput) (types.Page[ocirepos.OciRepo], error) { + got = input + return types.Page[ocirepos.OciRepo]{}, nil + }, + }} + + _, _, err := HandleListOciRepos(c)(context.Background(), nil, ListOciReposInput{ + Attributes: []AttributeFilterInput{{Key: "team", In: []string{"platform", "data"}}}, + CreatedAfter: "2026-01-15T00:00:00Z", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.Attributes) != 1 || len(got.Attributes[0].In) != 2 { + t.Errorf("Attributes = %+v, want one entry with 2 values", got.Attributes) + } + if got.CreatedAfter.IsZero() { + t.Error("CreatedAfter should be set") + } + if !got.CreatedBefore.IsZero() { + t.Error("CreatedBefore should stay zero so that bound stays open") + } +} diff --git a/mcp/tools/organizations.go b/mcp/tools/organizations.go index 909a42b..1ae10c8 100644 --- a/mcp/tools/organizations.go +++ b/mcp/tools/organizations.go @@ -30,6 +30,78 @@ func HandleGetOrganization(c *Client) func(context.Context, *mcpsdk.CallToolRequ } } +// OrganizationSettings is the tool-facing shape of the organization's +// behavior settings. The SDK's organizations.Settings carries no JSON tags, so +// it would serialize with Go field names; wrapping it keeps the wire format +// consistent with every other tool result. +type OrganizationSettings struct { + DefaultBundleAccess string `json:"defaultBundleAccess"` +} + +func toOrganizationSettings(s *organizations.Settings) OrganizationSettings { + return OrganizationSettings{DefaultBundleAccess: string(s.DefaultBundleAccess)} +} + +var GetOrganizationSettingsTool = &mcpsdk.Tool{ + Name: "get_organization_settings", + Description: "Gets the organization's behavior settings. Currently returns `defaultBundleAccess`: the access new bundle " + + "repositories receive when created — NONE (each new repository stays restricted until a grant is authored) or " + + "ALL_PROJECTS (each new bundle repository gets an org-wide repo:pull grant, making its bundles usable by every project). " + + "Every setting has a default, so an organization created before a setting existed reads it as the default. " + + "Requires the organization:manageSettings action (organization admins); other callers get a forbidden error.", +} + +type GetOrganizationSettingsInput struct{} + +func HandleGetOrganizationSettings(c *Client) func(context.Context, *mcpsdk.CallToolRequest, GetOrganizationSettingsInput) (*mcpsdk.CallToolResult, any, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ GetOrganizationSettingsInput) (*mcpsdk.CallToolResult, any, error) { + settings, err := c.Organizations.GetSettings(ctx) + if err != nil { + return nil, nil, fmt.Errorf("get_organization_settings: %w", err) + } + + out := toOrganizationSettings(settings) + result, err := jsonResult(out) + if err != nil { + return nil, nil, err + } + return result, out, nil + } +} + +var UpdateOrganizationSettingsTool = &mcpsdk.Tool{ + Name: "update_organization_settings", + Description: "Updates the organization's behavior settings and returns the resulting settings. Only the settings you provide " + + "are changed. Changing `default_bundle_access` affects only repositories created afterwards — access to existing " + + "repositories is managed through their grants (see create_oci_repo_grant). " + + "Requires the organization:manageSettings action (organization admins); other callers get a forbidden error.", +} + +type UpdateOrganizationSettingsInput struct { + DefaultBundleAccess string `json:"default_bundle_access,omitempty" jsonschema:"Optional. Access granted to new bundle repositories at creation: NONE (each new repository stays restricted until a grant is authored) or ALL_PROJECTS (each new bundle repository gets an org-wide repo:pull grant, revocable like any other grant). Omit to leave unchanged."` +} + +func HandleUpdateOrganizationSettings(c *Client) func(context.Context, *mcpsdk.CallToolRequest, UpdateOrganizationSettingsInput) (*mcpsdk.CallToolResult, any, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args UpdateOrganizationSettingsInput) (*mcpsdk.CallToolResult, any, error) { + settings, err := c.Organizations.UpdateSettings(ctx, organizations.UpdateSettingsInput{ + DefaultBundleAccess: organizations.DefaultBundleAccess(args.DefaultBundleAccess), + }) + if err != nil { + if isMutationFailed(err) { + return errorResult(fmt.Sprintf("update_organization_settings failed: %s", mutationErr(err))), nil, nil + } + return nil, nil, fmt.Errorf("update_organization_settings: %w", err) + } + + out := toOrganizationSettings(settings) + result, err := jsonResult(out) + if err != nil { + return nil, nil, err + } + return result, out, nil + } +} + var CreateCustomAttributeTool = &mcpsdk.Tool{ Name: "create_custom_attribute", Description: "Creates a custom attribute definition for the organization. " + diff --git a/mcp/tools/organizations_test.go b/mcp/tools/organizations_test.go index 37701c7..9b6607c 100644 --- a/mcp/tools/organizations_test.go +++ b/mcp/tools/organizations_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "errors" "strings" "testing" @@ -11,6 +12,8 @@ import ( type stubOrganizations struct { getFn func(context.Context) (*organizations.Organization, error) + getSettingsFn func(context.Context) (*organizations.Settings, error) + updateSettingsFn func(context.Context, organizations.UpdateSettingsInput) (*organizations.Settings, error) createCustomAttributeFn func(context.Context, organizations.CreateCustomAttributeInput) (*organizations.CustomAttribute, error) updateCustomAttributeFn func(context.Context, string, organizations.UpdateCustomAttributeInput) (*organizations.CustomAttribute, error) deleteCustomAttributeFn func(context.Context, string) (*organizations.CustomAttribute, error) @@ -21,6 +24,12 @@ type stubOrganizations struct { func (s *stubOrganizations) Get(ctx context.Context) (*organizations.Organization, error) { return s.getFn(ctx) } +func (s *stubOrganizations) GetSettings(ctx context.Context) (*organizations.Settings, error) { + return s.getSettingsFn(ctx) +} +func (s *stubOrganizations) UpdateSettings(ctx context.Context, input organizations.UpdateSettingsInput) (*organizations.Settings, error) { + return s.updateSettingsFn(ctx, input) +} func (s *stubOrganizations) CreateCustomAttribute(ctx context.Context, input organizations.CreateCustomAttributeInput) (*organizations.CustomAttribute, error) { return s.createCustomAttributeFn(ctx, input) } @@ -319,3 +328,111 @@ func TestHandleListCustomAttributes(t *testing.T) { t.Errorf("expected attribute in result, got: %s", resultText(t, result)) } } + +func TestHandleGetOrganizationSettings(t *testing.T) { + tests := []struct { + name string + stub *stubOrganizations + wantErr string + wantText string + }{ + { + name: "returns settings with camelCase keys", + stub: &stubOrganizations{ + getSettingsFn: func(context.Context) (*organizations.Settings, error) { + return &organizations.Settings{DefaultBundleAccess: organizations.DefaultBundleAccessAllProjects}, nil + }, + }, + wantText: "\"defaultBundleAccess\": \"ALL_PROJECTS\"", + }, + { + name: "propagates a forbidden error", + stub: &stubOrganizations{ + getSettingsFn: func(context.Context) (*organizations.Settings, error) { + return nil, errors.New("forbidden") + }, + }, + wantErr: "get_organization_settings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{Organizations: tt.stub} + result, _, err := HandleGetOrganizationSettings(c)(context.Background(), nil, GetOrganizationSettingsInput{}) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := resultText(t, result); !strings.Contains(got, tt.wantText) { + t.Errorf("expected %q in result, got: %s", tt.wantText, got) + } + }) + } +} + +func TestHandleUpdateOrganizationSettings(t *testing.T) { + t.Run("passes the requested access through and returns the result", func(t *testing.T) { + var got organizations.UpdateSettingsInput + c := &Client{Organizations: &stubOrganizations{ + updateSettingsFn: func(_ context.Context, input organizations.UpdateSettingsInput) (*organizations.Settings, error) { + got = input + return &organizations.Settings{DefaultBundleAccess: input.DefaultBundleAccess}, nil + }, + }} + + result, _, err := HandleUpdateOrganizationSettings(c)(context.Background(), nil, + UpdateOrganizationSettingsInput{DefaultBundleAccess: "NONE"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.DefaultBundleAccess != organizations.DefaultBundleAccessNone { + t.Errorf("DefaultBundleAccess = %q, want NONE", got.DefaultBundleAccess) + } + if text := resultText(t, result); !strings.Contains(text, "\"defaultBundleAccess\": \"NONE\"") { + t.Errorf("expected NONE in result, got: %s", text) + } + }) + + t.Run("omitted setting is left empty so the server keeps its current value", func(t *testing.T) { + var got organizations.UpdateSettingsInput + c := &Client{Organizations: &stubOrganizations{ + updateSettingsFn: func(_ context.Context, input organizations.UpdateSettingsInput) (*organizations.Settings, error) { + got = input + return &organizations.Settings{}, nil + }, + }} + + if _, _, err := HandleUpdateOrganizationSettings(c)(context.Background(), nil, UpdateOrganizationSettingsInput{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.DefaultBundleAccess != "" { + t.Errorf("DefaultBundleAccess = %q, want empty", got.DefaultBundleAccess) + } + }) + + t.Run("mutation failure returns a tool error", func(t *testing.T) { + c := &Client{Organizations: &stubOrganizations{ + updateSettingsFn: func(context.Context, organizations.UpdateSettingsInput) (*organizations.Settings, error) { + return nil, mutationFailedErr("updateOrganizationSettings", "defaultBundleAccess", "is invalid") + }, + }} + + result, _, err := HandleUpdateOrganizationSettings(c)(context.Background(), nil, + UpdateOrganizationSettingsInput{DefaultBundleAccess: "BOGUS"}) + if err != nil { + t.Fatalf("expected handled failure (nil error), got: %v", err) + } + if !result.IsError { + t.Error("expected IsError=true on mutation failure") + } + if got := resultText(t, result); !strings.Contains(got, "update_organization_settings failed") { + t.Errorf("expected failure text, got: %s", got) + } + }) +} diff --git a/mcp/tools/projects.go b/mcp/tools/projects.go index 3748230..e39881c 100644 --- a/mcp/tools/projects.go +++ b/mcp/tools/projects.go @@ -19,21 +19,34 @@ var ListProjectsTool = &mcpsdk.Tool{ } type ListProjectsInput struct { - Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` - PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` - Search string `json:"search,omitempty" jsonschema:"Optional. Free-text search across each project's name and description. Matches whole words anywhere in the text and is forgiving of partial or out-of-order terms. When set, results are ranked by relevance."` - Name string `json:"name,omitempty" jsonschema:"Optional. Filters to projects whose display name exactly equals this value. Use search for partial matching. Mutually exclusive with name_in."` - NameIn []string `json:"name_in,omitempty" jsonschema:"Optional. Filters to projects whose display name is any of these exact values. Mutually exclusive with name."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` + PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` + Search string `json:"search,omitempty" jsonschema:"Optional. Free-text search across each project's name and description. Matches whole words anywhere in the text and is forgiving of partial or out-of-order terms. When set, results are ranked by relevance."` + Name string `json:"name,omitempty" jsonschema:"Optional. Filters to projects whose display name exactly equals this value. Use search for partial matching. Mutually exclusive with name_in."` + NameIn []string `json:"name_in,omitempty" jsonschema:"Optional. Filters to projects whose display name is any of these exact values. Mutually exclusive with name."` + CreatedAfter string `json:"created_after,omitempty" jsonschema:"Optional. Only projects created at or after this instant, as an RFC 3339 timestamp (e.g. '2026-01-15T00:00:00Z'). Bounds are inclusive; omit to leave this side open."` + CreatedBefore string `json:"created_before,omitempty" jsonschema:"Optional. Only projects created at or before this instant, as an RFC 3339 timestamp (e.g. '2026-01-15T00:00:00Z'). Bounds are inclusive; omit to leave this side open."` } func HandleListProjects(c *Client) func(context.Context, *mcpsdk.CallToolRequest, ListProjectsInput) (*mcpsdk.CallToolResult, any, error) { return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args ListProjectsInput) (*mcpsdk.CallToolResult, any, error) { + createdAfter, err := parseTimestamp("list_projects", "created_after", args.CreatedAfter) + if err != nil { + return nil, nil, err + } + createdBefore, err := parseTimestamp("list_projects", "created_before", args.CreatedBefore) + if err != nil { + return nil, nil, err + } + page, err := c.Projects.ListPage(ctx, projects.ListInput{ - PageSize: clampPageSize(args.PageSize), - After: args.Cursor, - Search: args.Search, - Name: args.Name, - NameIn: args.NameIn, + PageSize: clampPageSize(args.PageSize), + After: args.Cursor, + Search: args.Search, + Name: args.Name, + NameIn: args.NameIn, + CreatedAfter: createdAfter, + CreatedBefore: createdBefore, }) if err != nil { return nil, nil, fmt.Errorf("list_projects: %w", err) diff --git a/mcp/tools/projects_test.go b/mcp/tools/projects_test.go index 474817f..af10b85 100644 --- a/mcp/tools/projects_test.go +++ b/mcp/tools/projects_test.go @@ -411,3 +411,25 @@ func TestHandleCloneProject(t *testing.T) { }) } } + +// TestListProjectsForwardsCreatedWindow verifies the created-at bounds reach the SDK. +func TestListProjectsForwardsCreatedWindow(t *testing.T) { + var got projects.ListInput + c := &Client{Projects: &stubProjects{ + listPageFn: func(_ context.Context, input projects.ListInput) (types.Page[projects.Project], error) { + got = input + return types.Page[projects.Project]{}, nil + }, + }} + + _, _, err := HandleListProjects(c)(context.Background(), nil, ListProjectsInput{ + CreatedAfter: "2026-01-15T00:00:00Z", + CreatedBefore: "2026-02-15T00:00:00Z", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.CreatedAfter.IsZero() || got.CreatedBefore.IsZero() { + t.Errorf("created window = [%v, %v], want both bounds set", got.CreatedAfter, got.CreatedBefore) + } +} diff --git a/mcp/tools/resource_types.go b/mcp/tools/resource_types.go new file mode 100644 index 0000000..ef550ae --- /dev/null +++ b/mcp/tools/resource_types.go @@ -0,0 +1,71 @@ +package tools + +import ( + "context" + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var GetResourceTypeTool = &mcpsdk.Tool{ + Name: "get_resource_type", + Description: "Gets a resource type — the contract behind Massdriver's connection system. Every dependency a bundle declares " + + "and every resource a bundle produces references one. " + + "Returns `schema` (the JSON Schema for the data the type exposes, and the shape create_resource expects in `payload`), " + + "`uiSchema` (rendering hints for the import form), `instructions` (step-by-step import instructions in markdown, typically one entry per workflow such as CLI and cloud console), " + + "`connectionOrientation` (LINK when the dependency is wired explicitly between instances, ENVIRONMENT_DEFAULT when it is satisfied by an environment-level default), " + + "and `effectiveAttributes` (auto-injected md-* system attributes). " + + "Call this before create_resource to learn what payload the type requires.", +} + +type GetResourceTypeInput struct { + ID string `json:"id" jsonschema:"The resource type identifier, optionally with a version suffix. Accepts a bare identifier ('aws-iam-role', resolving to the newest stable release), an exact version ('aws-iam-role@1.2.3'), a tilde range ('aws-iam-role@~1.2' for the latest 1.2.x, 'aws-iam-role@~1' for the latest 1.x.x), or a channel ('aws-iam-role@latest', 'aws-iam-role@latest+dev' to include dev builds). The returned id always carries the fully resolved version."` +} + +func HandleGetResourceType(c *Client) func(context.Context, *mcpsdk.CallToolRequest, GetResourceTypeInput) (*mcpsdk.CallToolResult, any, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args GetResourceTypeInput) (*mcpsdk.CallToolResult, any, error) { + if args.ID == "" { + return nil, nil, fmt.Errorf("get_resource_type: id is required") + } + + resourceType, err := c.ResourceTypes.Get(ctx, args.ID) + if err != nil { + return nil, nil, fmt.Errorf("get_resource_type: %w", err) + } + + return jsonResultStripping(resourceType, "icon") + } +} + +var ListResourceTypeDependentsTool = &mcpsdk.Tool{ + Name: "list_resource_type_dependents", + Description: "Lists what depends on a resource type within one environment — the blast radius before you change or remove it, " + + "or before you repoint an environment default of that type. " + + "Returns one entry per (instance, dependency field) pair, so an instance whose bundle depends on the same type through two fields appears twice. " + + "The instance and resource type in each entry are slim references (id and name only); use get_instance or get_resource_type for the full shape. " + + "The result set is bounded by the environment, so it is returned whole rather than paginated.", +} + +type ListResourceTypeDependentsInput struct { + EnvironmentID string `json:"environment_id" jsonschema:"The environment to scope the search to (e.g. 'myproj-staging'). Dependents are always looked up within a single environment."` + ResourceTypeID string `json:"resource_type_id" jsonschema:"The resource type to find dependents of. Accepts a bare identifier ('aws-vpc') or a versioned one ('aws-vpc@1.0.0'); a version suffix is accepted but matching resolves at the type level, since bundles reference resource types without a version."` +} + +func HandleListResourceTypeDependents(c *Client) func(context.Context, *mcpsdk.CallToolRequest, ListResourceTypeDependentsInput) (*mcpsdk.CallToolResult, any, error) { + return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args ListResourceTypeDependentsInput) (*mcpsdk.CallToolResult, any, error) { + if args.EnvironmentID == "" { + return nil, nil, fmt.Errorf("list_resource_type_dependents: environment_id is required") + } + if args.ResourceTypeID == "" { + return nil, nil, fmt.Errorf("list_resource_type_dependents: resource_type_id is required") + } + + dependents, err := c.ResourceTypes.Dependents(ctx, args.EnvironmentID, args.ResourceTypeID) + if err != nil { + return nil, nil, fmt.Errorf("list_resource_type_dependents: %w", err) + } + + out := listResult(dependents) + return jsonResultStripping(out, "icon") + } +} diff --git a/mcp/tools/resource_types_test.go b/mcp/tools/resource_types_test.go new file mode 100644 index 0000000..853733e --- /dev/null +++ b/mcp/tools/resource_types_test.go @@ -0,0 +1,206 @@ +package tools + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/instances" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resourcetypes" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" +) + +type stubResourceTypes struct { + getFn func(context.Context, string) (*resourcetypes.ResourceType, error) + dependentsFn func(context.Context, string, string) ([]resourcetypes.Dependent, error) +} + +func (s *stubResourceTypes) Get(ctx context.Context, id string) (*resourcetypes.ResourceType, error) { + return s.getFn(ctx, id) +} +func (s *stubResourceTypes) Dependents(ctx context.Context, environmentID, resourceTypeID string) ([]resourcetypes.Dependent, error) { + return s.dependentsFn(ctx, environmentID, resourceTypeID) +} + +func TestHandleGetResourceType(t *testing.T) { + tests := []struct { + name string + input GetResourceTypeInput + stub *stubResourceTypes + wantErr string + wantText string + absent string + }{ + { + name: "missing id", + input: GetResourceTypeInput{}, + stub: &stubResourceTypes{}, + wantErr: "id is required", + }, + { + name: "returns the schema the payload must satisfy", + input: GetResourceTypeInput{ID: "aws-iam-role"}, + stub: &stubResourceTypes{ + getFn: func(_ context.Context, id string) (*resourcetypes.ResourceType, error) { + return &resourcetypes.ResourceType{ + ID: id + "@1.2.3", + Name: "AWS IAM Role", + Version: "1.2.3", + Schema: map[string]any{"required": []any{"arn"}}, + }, nil + }, + }, + wantText: "aws-iam-role@1.2.3", + }, + { + name: "strips the icon blob", + input: GetResourceTypeInput{ID: "aws-iam-role"}, + stub: &stubResourceTypes{ + getFn: func(context.Context, string) (*resourcetypes.ResourceType, error) { + return &resourcetypes.ResourceType{ID: "aws-iam-role@1.0.0", Icon: ""}, nil + }, + }, + wantText: "aws-iam-role@1.0.0", + absent: "svg", + }, + { + name: "propagates lookup failure", + input: GetResourceTypeInput{ID: "nope"}, + stub: &stubResourceTypes{ + getFn: func(context.Context, string) (*resourcetypes.ResourceType, error) { + return nil, errors.New("not found") + }, + }, + wantErr: "get_resource_type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ResourceTypes: tt.stub} + result, _, err := HandleGetResourceType(c)(context.Background(), nil, tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := resultText(t, result) + if !strings.Contains(got, tt.wantText) { + t.Errorf("expected %q in result, got: %s", tt.wantText, got) + } + if tt.absent != "" && strings.Contains(got, tt.absent) { + t.Errorf("expected %q to be stripped from result, got: %s", tt.absent, got) + } + }) + } +} + +func TestHandleGetResourceTypePassesVersionedIDThrough(t *testing.T) { + var gotID string + c := &Client{ResourceTypes: &stubResourceTypes{ + getFn: func(_ context.Context, id string) (*resourcetypes.ResourceType, error) { + gotID = id + return &resourcetypes.ResourceType{ID: id}, nil + }, + }} + + if _, _, err := HandleGetResourceType(c)(context.Background(), nil, GetResourceTypeInput{ID: "aws-vpc@~1.2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotID != "aws-vpc@~1.2" { + t.Errorf("resource type id = %q, want the version suffix preserved", gotID) + } +} + +func TestHandleListResourceTypeDependents(t *testing.T) { + tests := []struct { + name string + input ListResourceTypeDependentsInput + stub *stubResourceTypes + wantErr string + wantText string + }{ + { + name: "missing environment_id", + input: ListResourceTypeDependentsInput{ResourceTypeID: "aws-vpc"}, + stub: &stubResourceTypes{}, + wantErr: "environment_id is required", + }, + { + name: "missing resource_type_id", + input: ListResourceTypeDependentsInput{EnvironmentID: "myproj-staging"}, + stub: &stubResourceTypes{}, + wantErr: "resource_type_id is required", + }, + { + name: "returns one entry per instance and field", + input: ListResourceTypeDependentsInput{EnvironmentID: "myproj-staging", ResourceTypeID: "aws-vpc"}, + stub: &stubResourceTypes{ + dependentsFn: func(context.Context, string, string) ([]resourcetypes.Dependent, error) { + return []resourcetypes.Dependent{ + {Instance: instances.Instance{ID: "inst1"}, Field: "network"}, + {Instance: instances.Instance{ID: "inst1"}, Field: "peer_network"}, + }, nil + }, + }, + wantText: "peer_network", + }, + { + name: "no dependents yields an empty array, not null", + input: ListResourceTypeDependentsInput{EnvironmentID: "myproj-staging", ResourceTypeID: "aws-vpc"}, + stub: &stubResourceTypes{ + dependentsFn: func(context.Context, string, string) ([]resourcetypes.Dependent, error) { + return nil, nil + }, + }, + wantText: "\"items\": []", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ResourceTypes: tt.stub} + result, _, err := HandleListResourceTypeDependents(c)(context.Background(), nil, tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := resultText(t, result); !strings.Contains(got, tt.wantText) { + t.Errorf("expected %q in result, got: %s", tt.wantText, got) + } + }) + } +} + +// TestListResourceTypeDependentsScopesToEnvironment guards the argument order — +// swapping the environment and resource type would silently return the wrong set. +func TestListResourceTypeDependentsScopesToEnvironment(t *testing.T) { + var gotEnv, gotType string + c := &Client{ResourceTypes: &stubResourceTypes{ + dependentsFn: func(_ context.Context, environmentID, resourceTypeID string) ([]resourcetypes.Dependent, error) { + gotEnv, gotType = environmentID, resourceTypeID + return []resourcetypes.Dependent{{ResourceType: types.ResourceType{ID: resourceTypeID}}}, nil + }, + }} + + _, _, err := HandleListResourceTypeDependents(c)(context.Background(), nil, ListResourceTypeDependentsInput{ + EnvironmentID: "myproj-staging", + ResourceTypeID: "aws-vpc", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotEnv != "myproj-staging" || gotType != "aws-vpc" { + t.Errorf("Dependents(env=%q, type=%q), want (myproj-staging, aws-vpc)", gotEnv, gotType) + } +} diff --git a/mcp/tools/resources.go b/mcp/tools/resources.go index 9eecbfe..3f0a34c 100644 --- a/mcp/tools/resources.go +++ b/mcp/tools/resources.go @@ -12,28 +12,43 @@ import ( var ListResourcesTool = &mcpsdk.Tool{ Name: "list_resources", Description: "Lists resources (provisioned or imported), one page at a time. " + - "STRONGLY PREFER filtering by `environment_id`, `resource_type`, `origin`, or `search` — unfiltered lists can span thousands of resources across an org. " + + "STRONGLY PREFER filtering by `environment_id`, `resource_type`, `origin`, `attributes`, or `search` — unfiltered lists can span thousands of resources across an org. " + "Returns up to `page_size` resources (default 25, max 100) plus a `next_cursor` for the following page. " + "To continue, call again with `cursor` set to the previous `next_cursor`. " + "Do NOT paginate to exhaustion unless the user explicitly asked for every resource.", } type ListResourcesInput struct { - Origin string `json:"origin,omitempty" jsonschema:"Optional. Filter by origin: IMPORTED or PROVISIONED."` - ResourceType string `json:"resource_type,omitempty" jsonschema:"Optional. Filter by resource type."` - EnvironmentID string `json:"environment_id,omitempty" jsonschema:"Optional. Filter to resources in this environment."` - Search string `json:"search,omitempty" jsonschema:"Optional. Search term to filter resources."` - Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` - PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` + Origin string `json:"origin,omitempty" jsonschema:"Optional. Filter by origin: IMPORTED or PROVISIONED."` + ResourceType string `json:"resource_type,omitempty" jsonschema:"Optional. Filter by resource type identifier (e.g. 'aws-iam-role'), optionally pinned to a published version with an @version suffix (e.g. 'aws-iam-role@1.2.3')."` + EnvironmentID string `json:"environment_id,omitempty" jsonschema:"Optional. Filter to resources in this environment."` + Search string `json:"search,omitempty" jsonschema:"Optional. Search term to filter resources."` + Attributes []AttributeFilterInput `json:"attributes,omitempty" jsonschema:"Optional. Filter by custom attributes. A provisioned resource matches attributes set anywhere on its instance chain (project, environment, component, instance) plus md-resource-type and md-id; an imported resource has no instance chain, so only md-resource-type and md-id can match it. Multiple entries are AND'd together."` + CreatedAfter string `json:"created_after,omitempty" jsonschema:"Optional. Only resources created at or after this instant, as an RFC 3339 timestamp (e.g. '2026-01-15T00:00:00Z'). Bounds are inclusive; omit to leave this side open."` + CreatedBefore string `json:"created_before,omitempty" jsonschema:"Optional. Only resources created at or before this instant, as an RFC 3339 timestamp (e.g. '2026-01-15T00:00:00Z'). Bounds are inclusive; omit to leave this side open."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional. Opaque cursor from a prior call's next_cursor. Omit for the first page."` + PageSize int `json:"page_size,omitempty" jsonschema:"Optional. Page size (1-100, default 25)."` } func HandleListResources(c *Client) func(context.Context, *mcpsdk.CallToolRequest, ListResourcesInput) (*mcpsdk.CallToolResult, any, error) { return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args ListResourcesInput) (*mcpsdk.CallToolResult, any, error) { + createdAfter, err := parseTimestamp("list_resources", "created_after", args.CreatedAfter) + if err != nil { + return nil, nil, err + } + createdBefore, err := parseTimestamp("list_resources", "created_before", args.CreatedBefore) + if err != nil { + return nil, nil, err + } + page, err := c.Resources.ListPage(ctx, resources.ListInput{ Origin: resources.Origin(args.Origin), ResourceType: args.ResourceType, EnvironmentID: args.EnvironmentID, Search: args.Search, + Attributes: toAttributeFilters(args.Attributes), + CreatedAfter: createdAfter, + CreatedBefore: createdBefore, PageSize: clampPageSize(args.PageSize), After: args.Cursor, }) @@ -113,14 +128,16 @@ func HandleExportResource(c *Client) func(context.Context, *mcpsdk.CallToolReque } var CreateResourceTool = &mcpsdk.Tool{ - Name: "create_resource", - Description: "Imports (creates) a resource by providing its type and payload data.", + Name: "create_resource", + Description: "Imports (creates) a resource by providing its type and payload data. " + + "Call get_resource_type first to learn the payload shape the type expects — it returns the JSON Schema the payload must satisfy, " + + "plus step-by-step instructions for gathering the values from the cloud provider.", } type CreateResourceInput struct { - ResourceTypeID string `json:"resource_type_id" jsonschema:"The resource type ID."` + ResourceTypeID string `json:"resource_type_id" jsonschema:"The resource type to import as. Accepts a bare identifier ('aws-iam-role', resolving to the newest stable release), an exact version ('aws-iam-role@1.2.3'), a tilde range ('aws-iam-role@~1'), or a channel ('aws-iam-role@latest')."` Name string `json:"name" jsonschema:"Display name for the resource."` - Payload map[string]any `json:"payload,omitempty" jsonschema:"Optional. Resource payload data."` + Payload map[string]any `json:"payload,omitempty" jsonschema:"Optional. Resource payload data, conforming to the resource type's schema. Use get_resource_type to retrieve that schema."` } func HandleCreateResource(c *Client) func(context.Context, *mcpsdk.CallToolRequest, CreateResourceInput) (*mcpsdk.CallToolResult, any, error) { diff --git a/mcp/tools/resources_test.go b/mcp/tools/resources_test.go index ec66840..49f0885 100644 --- a/mcp/tools/resources_test.go +++ b/mcp/tools/resources_test.go @@ -573,3 +573,48 @@ func TestHandleDeleteResourceGrant(t *testing.T) { }) } } + +// TestListResourcesForwardsFilters verifies the newer filter arguments reach the +// SDK: a dropped filter would silently return a wider result set than asked for. +func TestListResourcesForwardsFilters(t *testing.T) { + var got resources.ListInput + c := &Client{Resources: &stubResources{ + listPageFn: func(_ context.Context, input resources.ListInput) (types.Page[resources.Resource], error) { + got = input + return types.Page[resources.Resource]{}, nil + }, + }} + + _, _, err := HandleListResources(c)(context.Background(), nil, ListResourcesInput{ + ResourceType: "aws-iam-role@1.2.3", + Attributes: []AttributeFilterInput{{Key: "team", Eq: "platform"}}, + CreatedAfter: "2026-01-15T00:00:00Z", + CreatedBefore: "2026-02-15T00:00:00Z", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ResourceType != "aws-iam-role@1.2.3" { + t.Errorf("ResourceType = %q, want the version suffix preserved", got.ResourceType) + } + if len(got.Attributes) != 1 || got.Attributes[0].Key != "team" { + t.Errorf("Attributes = %+v, want one entry keyed team", got.Attributes) + } + if got.CreatedAfter.IsZero() || got.CreatedBefore.IsZero() { + t.Errorf("created window = [%v, %v], want both bounds set", got.CreatedAfter, got.CreatedBefore) + } +} + +func TestListResourcesRejectsMalformedTimestamp(t *testing.T) { + c := &Client{Resources: &stubResources{ + listPageFn: func(context.Context, resources.ListInput) (types.Page[resources.Resource], error) { + t.Fatal("SDK should not be called when a timestamp fails to parse") + return types.Page[resources.Resource]{}, nil + }, + }} + + _, _, err := HandleListResources(c)(context.Background(), nil, ListResourcesInput{CreatedAfter: "last tuesday"}) + if err == nil || !strings.Contains(err.Error(), "created_after") { + t.Fatalf("expected a created_after parse error, got: %v", err) + } +} diff --git a/mcp/tools/schema_required_test.go b/mcp/tools/schema_required_test.go index 306ecf7..7923fc6 100644 --- a/mcp/tools/schema_required_test.go +++ b/mcp/tools/schema_required_test.go @@ -18,7 +18,7 @@ var allToolInputs = []any{ AbortDeploymentInput{}, AddComponentInput{}, AddGroupServiceAccountInput{}, AddGroupUserInput{}, ApproveDeploymentInput{}, CreateCustomAttributeInput{}, CreateDeploymentInput{}, CreateEnvironmentInput{}, CreateGroupInput{}, CreateOciRepoInput{}, CreatePolicyInput{}, CreateProjectInput{}, - CreateResourceGrantInput{}, CreateResourceInput{}, CreateServiceAccountInput{}, DeleteCustomAttributeInput{}, + CreateResourceGrantInput{}, CreateResourceInput{}, DeleteCustomAttributeInput{}, DeleteEnvironmentInput{}, DeleteGroupInput{}, DeletePolicyInput{}, DeleteProjectInput{}, DeleteResourceGrantInput{}, DeleteResourceInput{}, DeleteServiceAccountInput{}, EvaluatePoliciesBatchInput{}, EvaluatePolicyInput{}, ExplainPolicyInput{}, ExportResourceInput{}, GetAuditLogInput{}, @@ -45,6 +45,9 @@ var allToolInputs = []any{ SetComponentPositionInput{}, ListGroupMembersInput{}, ListGroupServiceAccountsInput{}, ListGroupInvitationsInput{}, ListGroupPoliciesInput{}, ListOrganizationMembersInput{}, ListCustomAttributesInput{}, + GetResourceTypeInput{}, ListResourceTypeDependentsInput{}, + ListUnfulfilledDependenciesInput{}, ListEnvironmentLinksInput{}, + GetOrganizationSettingsInput{}, UpdateOrganizationSettingsInput{}, } // TestOptionalFieldsAreNotRequired guards against a subtle schema bug: the @@ -84,7 +87,7 @@ func TestOptionalFieldsAreNotRequired(t *testing.T) { // remove a tool, update wantTools to match. The dedup check catches copy/paste // mistakes in the list above. func TestInputCoverageMatchesRegisteredTools(t *testing.T) { - const wantTools = 106 // must equal the number of AddTool calls in mcp/server.go + const wantTools = 111 // must equal the number of AddTool calls in mcp/server.go covered := make(map[string]bool, len(allToolInputs)) for _, in := range allToolInputs { diff --git a/mcp/tools/service_accounts.go b/mcp/tools/service_accounts.go index b7351a5..ed58827 100644 --- a/mcp/tools/service_accounts.go +++ b/mcp/tools/service_accounts.go @@ -71,43 +71,6 @@ func HandleGetServiceAccount(c *Client) func(context.Context, *mcpsdk.CallToolRe } } -var CreateServiceAccountTool = &mcpsdk.Tool{ - Name: "create_service_account", - Description: "Creates a new service account in the organization. The response includes the bearer token which is only shown once.", -} - -type CreateServiceAccountInput struct { - Name string `json:"name" jsonschema:"The name of the service account."` - Description string `json:"description,omitempty" jsonschema:"Optional. A description of the service account."` - DefaultAccessTokenExpirationInMinutes int `json:"default_access_token_expiration_in_minutes,omitempty" jsonschema:"Optional. Default expiration time for access tokens in minutes."` -} - -func HandleCreateServiceAccount(c *Client) func(context.Context, *mcpsdk.CallToolRequest, CreateServiceAccountInput) (*mcpsdk.CallToolResult, any, error) { - return func(ctx context.Context, _ *mcpsdk.CallToolRequest, args CreateServiceAccountInput) (*mcpsdk.CallToolResult, any, error) { - if args.Name == "" { - return nil, nil, fmt.Errorf("create_service_account: name is required") - } - - created, err := c.ServiceAccounts.Create(ctx, serviceaccounts.CreateInput{ - Name: args.Name, - Description: args.Description, - DefaultAccessTokenExpirationInMinutes: args.DefaultAccessTokenExpirationInMinutes, - }) - if err != nil { - if isMutationFailed(err) { - return errorResult(fmt.Sprintf("create_service_account failed: %s", mutationErr(err))), nil, nil - } - return nil, nil, fmt.Errorf("create_service_account: %w", err) - } - - result, err := jsonResult(created) - if err != nil { - return nil, nil, err - } - return result, created, nil - } -} - var UpdateServiceAccountTool = &mcpsdk.Tool{ Name: "update_service_account", Description: "Updates a service account's name or description.", diff --git a/mcp/tools/service_accounts_test.go b/mcp/tools/service_accounts_test.go index 6943cb6..1034f0f 100644 --- a/mcp/tools/service_accounts_test.go +++ b/mcp/tools/service_accounts_test.go @@ -12,7 +12,6 @@ import ( type stubServiceAccounts struct { listPageFn func(context.Context, serviceaccounts.ListInput) (types.Page[serviceaccounts.ServiceAccount], error) getFn func(context.Context, string) (*serviceaccounts.ServiceAccount, error) - createFn func(context.Context, serviceaccounts.CreateInput) (*serviceaccounts.Created, error) updateFn func(context.Context, string, serviceaccounts.UpdateInput) (*serviceaccounts.ServiceAccount, error) deleteFn func(context.Context, string) (*serviceaccounts.ServiceAccount, error) } @@ -23,9 +22,6 @@ func (s *stubServiceAccounts) ListPage(ctx context.Context, input serviceaccount func (s *stubServiceAccounts) Get(ctx context.Context, id string) (*serviceaccounts.ServiceAccount, error) { return s.getFn(ctx, id) } -func (s *stubServiceAccounts) Create(ctx context.Context, input serviceaccounts.CreateInput) (*serviceaccounts.Created, error) { - return s.createFn(ctx, input) -} func (s *stubServiceAccounts) Update(ctx context.Context, id string, input serviceaccounts.UpdateInput) (*serviceaccounts.ServiceAccount, error) { return s.updateFn(ctx, id, input) } @@ -133,66 +129,6 @@ func TestHandleGetServiceAccount(t *testing.T) { } } -func TestHandleCreateServiceAccount(t *testing.T) { - tests := []struct { - name string - input CreateServiceAccountInput - stub *stubServiceAccounts - wantErr string - wantText string - }{ - { - name: "missing name", - input: CreateServiceAccountInput{}, - stub: &stubServiceAccounts{}, - wantErr: "name is required", - }, - { - name: "success returns created JSON", - input: CreateServiceAccountInput{Name: "CI Bot"}, - stub: &stubServiceAccounts{ - createFn: func(_ context.Context, input serviceaccounts.CreateInput) (*serviceaccounts.Created, error) { - return &serviceaccounts.Created{ - ServiceAccount: serviceaccounts.ServiceAccount{ID: "sa1", Name: input.Name}, - DefaultToken: "tok_secret", - }, nil - }, - }, - wantText: "sa1", - }, - { - name: "mutation failure returns error message", - input: CreateServiceAccountInput{Name: "CI Bot"}, - stub: &stubServiceAccounts{ - createFn: func(context.Context, serviceaccounts.CreateInput) (*serviceaccounts.Created, error) { - return nil, mutationFailedErr("create service account", "name", "already exists") - }, - }, - wantText: "create_service_account failed", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Client{ServiceAccounts: tt.stub} - handler := HandleCreateServiceAccount(c) - result, _, err := handler(context.Background(), nil, tt.input) - if tt.wantErr != "" { - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(resultText(t, result), tt.wantText) { - t.Errorf("expected %q in result, got: %s", tt.wantText, resultText(t, result)) - } - }) - } -} - func TestHandleUpdateServiceAccount(t *testing.T) { tests := []struct { name string diff --git a/mcp/tools/services.go b/mcp/tools/services.go index 8c9c1de..cf835b9 100644 --- a/mcp/tools/services.go +++ b/mcp/tools/services.go @@ -17,6 +17,7 @@ import ( "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/policies" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/projects" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resources" + "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/resourcetypes" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/server" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/serviceaccounts" "github.com/massdriver-cloud/massdriver-sdk-go/massdriver/platform/types" @@ -35,6 +36,7 @@ type Client struct { Components ComponentsService Bundles BundlesService Resources ResourcesService + ResourceTypes ResourceTypesService Organizations OrganizationsService Viewer ViewerService AuditLogs AuditLogsService @@ -60,6 +62,8 @@ type ProjectsService interface { type EnvironmentsService interface { ListPage(ctx context.Context, input environments.ListInput) (types.Page[environments.Environment], error) Get(ctx context.Context, id string) (*environments.Environment, error) + Links(ctx context.Context, id string) ([]types.Link, error) + UnfulfilledDependencies(ctx context.Context, id string) ([]environments.UnfulfilledDependency, error) Create(ctx context.Context, projectID string, input environments.CreateInput) (*environments.Environment, error) Update(ctx context.Context, id string, input environments.UpdateInput) (*environments.Environment, error) Delete(ctx context.Context, id string) (*environments.Environment, error) @@ -131,9 +135,17 @@ type ResourcesService interface { ListGrantsPage(ctx context.Context, resourceID string, input resources.ListGrantsInput) (types.Page[resources.Grant], error) } +// ResourceTypesService defines the resource type operations used by tool handlers. +type ResourceTypesService interface { + Get(ctx context.Context, id string) (*resourcetypes.ResourceType, error) + Dependents(ctx context.Context, environmentID, resourceTypeID string) ([]resourcetypes.Dependent, error) +} + // OrganizationsService defines the organization operations used by tool handlers. type OrganizationsService interface { Get(ctx context.Context) (*organizations.Organization, error) + GetSettings(ctx context.Context) (*organizations.Settings, error) + UpdateSettings(ctx context.Context, input organizations.UpdateSettingsInput) (*organizations.Settings, error) CreateCustomAttribute(ctx context.Context, input organizations.CreateCustomAttributeInput) (*organizations.CustomAttribute, error) UpdateCustomAttribute(ctx context.Context, id string, input organizations.UpdateCustomAttributeInput) (*organizations.CustomAttribute, error) DeleteCustomAttribute(ctx context.Context, id string) (*organizations.CustomAttribute, error) @@ -175,7 +187,6 @@ type GroupsService interface { type ServiceAccountsService interface { ListPage(ctx context.Context, input serviceaccounts.ListInput) (types.Page[serviceaccounts.ServiceAccount], error) Get(ctx context.Context, id string) (*serviceaccounts.ServiceAccount, error) - Create(ctx context.Context, input serviceaccounts.CreateInput) (*serviceaccounts.Created, error) Update(ctx context.Context, id string, input serviceaccounts.UpdateInput) (*serviceaccounts.ServiceAccount, error) Delete(ctx context.Context, id string) (*serviceaccounts.ServiceAccount, error) }