diff --git a/.github/workflows/code-health-fork.yml b/.github/workflows/code-health-fork.yml index 4d0e25a53..2aba1b655 100644 --- a/.github/workflows/code-health-fork.yml +++ b/.github/workflows/code-health-fork.yml @@ -38,7 +38,7 @@ jobs: - name: Run tests run: pnpm test env: - SKIP_ATLAS_TESTS: "true" + SKIP_ATLAS_INTEGRATION_TESTS: "true" SKIP_ATLAS_LOCAL_TESTS: "true" run-atlas-local-tests: name: Run Atlas Local tests diff --git a/.github/workflows/code-health.yml b/.github/workflows/code-health.yml index b13eba037..f8c5d63c1 100644 --- a/.github/workflows/code-health.yml +++ b/.github/workflows/code-health.yml @@ -39,7 +39,7 @@ jobs: - name: Run tests run: pnpm test env: - SKIP_ATLAS_TESTS: "true" + SKIP_ATLAS_INTEGRATION_TESTS: "true" SKIP_ATLAS_LOCAL_TESTS: "true" MDB_MONGOT_PASSWORD: ${{ secrets.TEST_MDB_MONGOT_PASSWORD }} MDB_VOYAGE_API_KEY: ${{ secrets.TEST_MDB_MCP_VOYAGE_API_KEY }} diff --git a/README.md b/README.md index d176ed310..9d981a815 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,10 @@ For more information, see the [Copilot CLI documentation](https://docs.github.co - `atlas-list-db-users` - List MongoDB Atlas database users - `atlas-list-orgs` - List MongoDB Atlas organizations - `atlas-list-projects` - List MongoDB Atlas projects +- `atlas-streams-build` - Create Atlas Stream Processing resources. Use this tool for 'set up a Kafka pipeline', 'create a workspace', 'add a connection', or 'deploy a processor'. Use resource='workspace' to create a new workspace (specify cloud provider, region, and tier). Use resource='connection' to add a data source or sink to an existing workspace. Use resource='processor' to deploy a stream processor with an aggregation pipeline. Use resource='privatelink' to set up private networking. Typical workflow: create workspace → add connections → deploy processor. +- `atlas-streams-discover` - Discover and inspect Atlas Stream Processing resources. Also use for 'why is my processor failing', 'what workspaces do I have', 'show processor stats', or 'check processor health'. Use 'list-workspaces' to see all workspaces in a project. Use inspect actions for details on a specific resource. Use 'diagnose-processor' for a combined health report including state, stats, connection health, and recent errors. Use 'get-logs' for operational or audit logs and 'get-networking' for PrivateLink and account details. +- `atlas-streams-manage` - Manage Atlas Stream Processing resources: start/stop processors, modify pipelines, update configurations. Also use for 'change the pipeline', 'scale up my processor', or 'update my workspace tier'. Common workflow: action='stop-processor' → action='modify-processor' → action='start-processor'. Use `atlas-streams-discover` with action 'inspect-processor' to check state before managing. +- `atlas-streams-teardown` - Delete Atlas Stream Processing resources. Also use for 'remove my workspace', 'delete all processors', or 'clean up my streams environment'. Performs safety checks before deletion: warns about connections referenced by running processors, summarizes workspace contents before deletion, and recommends stopping running processors before deletion. Use `atlas-streams-discover` to review resources before deleting. NOTE: atlas tools are only available when you set credentials on [configuration](#configuration) section. @@ -383,43 +387,43 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow ### Configuration Options -| Environment Variable / CLI Option | Default | Description | -| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MDB_MCP_ALLOW_REQUEST_OVERRIDES` / `--allowRequestOverrides` | `false` | When set to true, allows configuration values to be overridden via request headers and query parameters. | -| `MDB_MCP_API_CLIENT_ID` / `--apiClientId` | `` | Atlas API client ID for authentication. Required for running Atlas tools. | -| `MDB_MCP_API_CLIENT_SECRET` / `--apiClientSecret` | `` | Atlas API client secret for authentication. Required for running Atlas tools. | -| `MDB_MCP_ASSISTANT_BASE_URL` / `--assistantBaseUrl` | `"https://knowledge.mongodb.com/api/v1/"` | Base URL for the MongoDB Assistant API. | -| `MDB_MCP_ATLAS_TEMPORARY_DATABASE_USER_LIFETIME_MS` / `--atlasTemporaryDatabaseUserLifetimeMs` | `14400000` | Time in milliseconds that temporary database users created when connecting to MongoDB Atlas clusters will remain active before being automatically deleted. | -| `MDB_MCP_CONFIRMATION_REQUIRED_TOOLS` / `--confirmationRequiredTools` | `"atlas-create-access-list,atlas-create-db-user,drop-database,drop-collection,delete-many,drop-index"` | Comma separated values of tool names that require user confirmation before execution. Requires the client to support elicitation. | -| `MDB_MCP_CONNECTION_STRING` / `--connectionString` | `` | MongoDB connection string for direct database connections. Optional, if not set, you'll need to call the connect tool before interacting with MongoDB data. | -| `MDB_MCP_DISABLED_TOOLS` / `--disabledTools` | `""` | Comma separated values of tool names, operation types, and/or categories of tools that will be disabled. | -| `MDB_MCP_DRY_RUN` / `--dryRun` | `false` | When true, runs the server in dry mode: dumps configuration and enabled tools, then exits without starting the server. | -| `MDB_MCP_EMBEDDINGS_VALIDATION` / `--embeddingsValidation` | `true` | When set to false, disables validation of embeddings dimensions. | -| `MDB_MCP_EXPORT_CLEANUP_INTERVAL_MS` / `--exportCleanupIntervalMs` | `120000` | Time in milliseconds between export cleanup cycles that remove expired export files. | -| `MDB_MCP_EXPORT_TIMEOUT_MS` / `--exportTimeoutMs` | `300000` | Time in milliseconds after which an export is considered expired and eligible for cleanup. | -| `MDB_MCP_EXPORTS_PATH` / `--exportsPath` | see below\* | Folder to store exported data files. | -| `MDB_MCP_EXTERNALLY_MANAGED_SESSIONS` / `--externallyManagedSessions` | `false` | When true, the HTTP transport allows requests with a session ID supplied externally through the 'mcp-session-id' header. When an external ID is supplied, the initialization request is optional. | -| `MDB_MCP_HEALTH_CHECK_HOST` / `--healthCheckHost` | `` | Host address to bind the healthCheck HTTP server to (only used when transport is 'http'). If provided, `healthCheckPort` must also be set. | -| `MDB_MCP_HEALTH_CHECK_PORT` / `--healthCheckPort` | `` | Port number for the healthCheck HTTP server (only used when transport is 'http'). If provided, `healthCheckHost` must also be set. | -| `MDB_MCP_HTTP_BODY_LIMIT` / `--httpBodyLimit` | `102400` | Maximum size of the HTTP request body in bytes (only used when transport is 'http'). This value is passed as the optional limit parameter to the Express.js json() middleware. | -| `MDB_MCP_HTTP_HEADERS` / `--httpHeaders` | `"{}"` | Header that the HTTP server will validate when making requests (only used when transport is 'http'). | -| `MDB_MCP_HTTP_HOST` / `--httpHost` | `"127.0.0.1"` | Host address to bind the HTTP server to (only used when transport is 'http'). | -| `MDB_MCP_HTTP_PORT` / `--httpPort` | `3000` | Port number for the HTTP server (only used when transport is 'http'). Use 0 for a random port. | -| `MDB_MCP_HTTP_RESPONSE_TYPE` / `--httpResponseType` | `"sse"` | The HTTP response type for tool responses: 'sse' for Server-Sent Events, 'json' for standard JSON responses. | -| `MDB_MCP_IDLE_TIMEOUT_MS` / `--idleTimeoutMs` | `600000` | Idle timeout for a client to disconnect (only applies to http transport). | -| `MDB_MCP_INDEX_CHECK` / `--indexCheck` | `false` | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. | -| `MDB_MCP_LOG_PATH` / `--logPath` | see below\* | Folder to store logs. | -| `MDB_MCP_LOGGERS` / `--loggers` | `"disk,mcp"` see below\* | Comma separated values of logger types. | -| `MDB_MCP_MAX_BYTES_PER_QUERY` / `--maxBytesPerQuery` | `16777216` | The maximum size in bytes for results from a find or aggregate tool call. This serves as an upper bound for the responseBytesLimit parameter in those tools. | -| `MDB_MCP_MAX_DOCUMENTS_PER_QUERY` / `--maxDocumentsPerQuery` | `100` | The maximum number of documents that can be returned by a find or aggregate tool call. For the find tool, the effective limit will be the smaller of this value and the tool's limit parameter. | -| `MDB_MCP_NOTIFICATION_TIMEOUT_MS` / `--notificationTimeoutMs` | `540000` | Notification timeout for a client to be aware of disconnect (only applies to http transport). | -| `MDB_MCP_PREVIEW_FEATURES` / `--previewFeatures` | `""` | Comma separated values of preview features that are enabled. | -| `MDB_MCP_READ_ONLY` / `--readOnly` | `false` | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. | -| `MDB_MCP_TELEMETRY` / `--telemetry` | `"enabled"` | When set to disabled, disables telemetry collection. | -| `MDB_MCP_TRANSPORT` / `--transport` | `"stdio"` | Either 'stdio' or 'http'. | -| `MDB_MCP_VECTOR_SEARCH_DIMENSIONS` / `--vectorSearchDimensions` | `1024` | Default number of dimensions for vector search embeddings. | -| `MDB_MCP_VECTOR_SEARCH_SIMILARITY_FUNCTION` / `--vectorSearchSimilarityFunction` | `"euclidean"` | Default similarity function for vector search: 'euclidean', 'cosine', or 'dotProduct'. | -| `MDB_MCP_VOYAGE_API_KEY` / `--voyageApiKey` | `""` | API key for Voyage AI embeddings service (required for vector search operations with text-to-embedding conversion). | +| Environment Variable / CLI Option | Default | Description | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MDB_MCP_ALLOW_REQUEST_OVERRIDES` / `--allowRequestOverrides` | `false` | When set to true, allows configuration values to be overridden via request headers and query parameters. | +| `MDB_MCP_API_CLIENT_ID` / `--apiClientId` | `` | Atlas API client ID for authentication. Required for running Atlas tools. | +| `MDB_MCP_API_CLIENT_SECRET` / `--apiClientSecret` | `` | Atlas API client secret for authentication. Required for running Atlas tools. | +| `MDB_MCP_ASSISTANT_BASE_URL` / `--assistantBaseUrl` | `"https://knowledge.mongodb.com/api/v1/"` | Base URL for the MongoDB Assistant API. | +| `MDB_MCP_ATLAS_TEMPORARY_DATABASE_USER_LIFETIME_MS` / `--atlasTemporaryDatabaseUserLifetimeMs` | `14400000` | Time in milliseconds that temporary database users created when connecting to MongoDB Atlas clusters will remain active before being automatically deleted. | +| `MDB_MCP_CONFIRMATION_REQUIRED_TOOLS` / `--confirmationRequiredTools` | `"atlas-create-access-list,atlas-create-db-user,drop-database,drop-collection,delete-many,drop-index,atlas-streams-manage,atlas-streams-teardown"` | Comma separated values of tool names that require user confirmation before execution. Requires the client to support elicitation. | +| `MDB_MCP_CONNECTION_STRING` / `--connectionString` | `` | MongoDB connection string for direct database connections. Optional, if not set, you'll need to call the connect tool before interacting with MongoDB data. | +| `MDB_MCP_DISABLED_TOOLS` / `--disabledTools` | `""` | Comma separated values of tool names, operation types, and/or categories of tools that will be disabled. | +| `MDB_MCP_DRY_RUN` / `--dryRun` | `false` | When true, runs the server in dry mode: dumps configuration and enabled tools, then exits without starting the server. | +| `MDB_MCP_EMBEDDINGS_VALIDATION` / `--embeddingsValidation` | `true` | When set to false, disables validation of embeddings dimensions. | +| `MDB_MCP_EXPORT_CLEANUP_INTERVAL_MS` / `--exportCleanupIntervalMs` | `120000` | Time in milliseconds between export cleanup cycles that remove expired export files. | +| `MDB_MCP_EXPORT_TIMEOUT_MS` / `--exportTimeoutMs` | `300000` | Time in milliseconds after which an export is considered expired and eligible for cleanup. | +| `MDB_MCP_EXPORTS_PATH` / `--exportsPath` | see below\* | Folder to store exported data files. | +| `MDB_MCP_EXTERNALLY_MANAGED_SESSIONS` / `--externallyManagedSessions` | `false` | When true, the HTTP transport allows requests with a session ID supplied externally through the 'mcp-session-id' header. When an external ID is supplied, the initialization request is optional. | +| `MDB_MCP_HEALTH_CHECK_HOST` / `--healthCheckHost` | `` | Host address to bind the healthCheck HTTP server to (only used when transport is 'http'). If provided, `healthCheckPort` must also be set. | +| `MDB_MCP_HEALTH_CHECK_PORT` / `--healthCheckPort` | `` | Port number for the healthCheck HTTP server (only used when transport is 'http'). If provided, `healthCheckHost` must also be set. | +| `MDB_MCP_HTTP_BODY_LIMIT` / `--httpBodyLimit` | `102400` | Maximum size of the HTTP request body in bytes (only used when transport is 'http'). This value is passed as the optional limit parameter to the Express.js json() middleware. | +| `MDB_MCP_HTTP_HEADERS` / `--httpHeaders` | `"{}"` | Header that the HTTP server will validate when making requests (only used when transport is 'http'). | +| `MDB_MCP_HTTP_HOST` / `--httpHost` | `"127.0.0.1"` | Host address to bind the HTTP server to (only used when transport is 'http'). | +| `MDB_MCP_HTTP_PORT` / `--httpPort` | `3000` | Port number for the HTTP server (only used when transport is 'http'). Use 0 for a random port. | +| `MDB_MCP_HTTP_RESPONSE_TYPE` / `--httpResponseType` | `"sse"` | The HTTP response type for tool responses: 'sse' for Server-Sent Events, 'json' for standard JSON responses. | +| `MDB_MCP_IDLE_TIMEOUT_MS` / `--idleTimeoutMs` | `600000` | Idle timeout for a client to disconnect (only applies to http transport). | +| `MDB_MCP_INDEX_CHECK` / `--indexCheck` | `false` | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. | +| `MDB_MCP_LOG_PATH` / `--logPath` | see below\* | Folder to store logs. | +| `MDB_MCP_LOGGERS` / `--loggers` | `"disk,mcp"` see below\* | Comma separated values of logger types. | +| `MDB_MCP_MAX_BYTES_PER_QUERY` / `--maxBytesPerQuery` | `16777216` | The maximum size in bytes for results from a find or aggregate tool call. This serves as an upper bound for the responseBytesLimit parameter in those tools. | +| `MDB_MCP_MAX_DOCUMENTS_PER_QUERY` / `--maxDocumentsPerQuery` | `100` | The maximum number of documents that can be returned by a find or aggregate tool call. For the find tool, the effective limit will be the smaller of this value and the tool's limit parameter. | +| `MDB_MCP_NOTIFICATION_TIMEOUT_MS` / `--notificationTimeoutMs` | `540000` | Notification timeout for a client to be aware of disconnect (only applies to http transport). | +| `MDB_MCP_PREVIEW_FEATURES` / `--previewFeatures` | `""` | Comma separated values of preview features that are enabled. | +| `MDB_MCP_READ_ONLY` / `--readOnly` | `false` | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. | +| `MDB_MCP_TELEMETRY` / `--telemetry` | `"enabled"` | When set to disabled, disables telemetry collection. | +| `MDB_MCP_TRANSPORT` / `--transport` | `"stdio"` | Either 'stdio' or 'http'. | +| `MDB_MCP_VECTOR_SEARCH_DIMENSIONS` / `--vectorSearchDimensions` | `1024` | Default number of dimensions for vector search embeddings. | +| `MDB_MCP_VECTOR_SEARCH_SIMILARITY_FUNCTION` / `--vectorSearchSimilarityFunction` | `"euclidean"` | Default similarity function for vector search: 'euclidean', 'cosine', or 'dotProduct'. | +| `MDB_MCP_VOYAGE_API_KEY` / `--voyageApiKey` | `""` | API key for Voyage AI embeddings service (required for vector search operations with text-to-embedding conversion). | #### Logger Options @@ -557,6 +561,11 @@ List of available preview features: - `search` - Enables tools or functionality related to Atlas Search and Vector Search in MongoDB Atlas: - Index management, such as creating, listing, and dropping search and vector search indexes. - Querying collections using vector search capabilities. This requires a configured embedding model that will be used to generate vector representations of the query data. Currently, only [Voyage AI](https://www.voyageai.com) embedding models are supported. Set the `voyageApiKey` configuration option with your Voyage AI API key to use this feature. +- `streams` - Enables Atlas Stream Processing tools for building, managing, and debugging streaming data pipelines: + - Create and manage stream processing workspaces, connections (Kafka, Cluster, S3, and more), and processors. + - Start, stop, modify, and monitor stream processors. + - Diagnose processor issues with combined health reports including state, stats, and connection health. + - Requires Atlas API credentials with appropriate stream processing permissions. See [Atlas API Permissions](#atlas-api-permissions). ### Atlas API Access @@ -591,14 +600,15 @@ To learn more about Service Accounts, check the [MongoDB Atlas documentation](ht #### Quick Reference: Required roles per operation -| What you want to do | Safest Role to Assign (where) | -| ------------------------------------ | --------------------------------------- | -| List orgs/projects | Org Member or Org Read Only (Org) | -| Create new projects | Org Project Creator (Org) | -| View clusters/databases in a project | Project Read Only (Project) | -| Create/manage clusters in a project | Project Cluster Manager (Project) | -| Manage project access lists | Project IP Access List Admin (Project) | -| Manage database users | Project Database Access Admin (Project) | +| What you want to do | Safest Role to Assign (where) | +| ------------------------------------ | ----------------------------------------- | +| List orgs/projects | Org Member or Org Read Only (Org) | +| Create new projects | Org Project Creator (Org) | +| View clusters/databases in a project | Project Read Only (Project) | +| Create/manage clusters in a project | Project Cluster Manager (Project) | +| Manage project access lists | Project IP Access List Admin (Project) | +| Manage database users | Project Database Access Admin (Project) | +| Manage stream processing resources | Project Stream Processing Owner (Project) | - **Prefer project-level roles** for most operations. Assign only to the specific projects you need to manage or view. - **Avoid Organization Owner** unless you require full administrative control over all projects and settings in the organization. diff --git a/package.json b/package.json index df1194b0c..3c6981f36 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "test:accuracy": "sh ./scripts/accuracy/runAccuracyTests.sh", "test:browser": "pnpm --filter browser test", "test:long-running-tests": "vitest --project long-running-tests --coverage", - "test:local": "SKIP_ATLAS_TESTS=true SKIP_ATLAS_LOCAL_TESTS=true pnpm run test", + "test:local": "SKIP_ATLAS_INTEGRATION_TESTS=true SKIP_ATLAS_LOCAL_TESTS=true pnpm run test", "atlas:cleanup": "vitest --project atlas-cleanup" }, "license": "Apache-2.0", diff --git a/scripts/cleanupAtlasTestLeftovers.test.ts b/scripts/cleanupAtlasTestLeftovers.test.ts index 0bf5e12a1..0e8d9401b 100644 --- a/scripts/cleanupAtlasTestLeftovers.test.ts +++ b/scripts/cleanupAtlasTestLeftovers.test.ts @@ -4,6 +4,10 @@ import { ConsoleLogger } from "../src/common/logging/index.js"; import { Keychain } from "../src/lib.js"; import { describe, it } from "vitest"; +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function isOlderThanTwoHours(date: string): boolean { const twoHoursInMs = 2 * 60 * 60 * 1000; const projectDate = new Date(date); @@ -35,6 +39,78 @@ async function findAllTestProjects(client: ApiClient, orgId: string): Promise isOlderThanTwoHours(proj.created)); } +async function deleteAllWorkspacesOnStaleProject(client: ApiClient, projectId: string): Promise { + const errors: string[] = []; + + try { + const workspaces = await client + .listStreamWorkspaces({ + params: { + path: { + groupId: projectId, + }, + }, + }) + .then((res) => res.results || []); + + await Promise.allSettled( + workspaces.map(async (workspace) => { + const name = workspace.name || ""; + try { + // Delete all processors first (auto-stops running ones) + try { + const processors = await client + .getStreamProcessors({ + params: { path: { groupId: projectId, tenantName: name } }, + }) + .then((res) => res.results || []); + await Promise.allSettled( + processors.map((p) => + client.deleteStreamProcessor({ + params: { + path: { + groupId: projectId, + tenantName: name, + processorName: p.name || "", + }, + }, + }) + ) + ); + } catch { + // Ignore errors listing/deleting processors + } + await client.deleteStreamWorkspace({ + params: { + path: { groupId: projectId, tenantName: name }, + }, + }); + // Wait for workspace to be fully deleted (up to 120s) + for (let i = 0; i < 120; i++) { + try { + await client.getStreamWorkspace({ + params: { + path: { groupId: projectId, tenantName: name }, + }, + }); + await sleep(1000); + } catch { + break; + } + } + console.log(` Deleted workspace: ${name}`); + } catch (error) { + errors.push(`Failed to delete workspace ${name} in project ${projectId}: ${String(error)}`); + } + }) + ); + } catch { + // Project may not have streams enabled, ignore + } + + return errors; +} + async function deleteAllClustersOnStaleProject(client: ApiClient, projectId: string): Promise { const errors: string[] = []; @@ -50,12 +126,13 @@ async function deleteAllClustersOnStaleProject(client: ApiClient, projectId: str await Promise.allSettled( allClusters.map(async (cluster) => { + const name = cluster.name || ""; try { await client.deleteCluster({ - params: { path: { groupId: projectId || "", clusterName: cluster.name || "" } }, + params: { path: { groupId: projectId || "", clusterName: name } }, }); } catch (error) { - errors.push(`Failed to delete cluster ${cluster.name} in project ${projectId}: ${String(error)}`); + errors.push(`Failed to delete cluster ${name} in project ${projectId}: ${String(error)}`); } }) ); @@ -88,34 +165,48 @@ async function main(): Promise { const allErrors: string[] = []; - for (const project of testProjects) { - console.log(`Cleaning up project: ${project.name} (${project.id})`); - if (!project.id) { - console.warn(`Skipping project with missing ID: ${project.name}`); - continue; - } + const projectsWithIds = testProjects.filter((p): p is Group & { id: string } => !!p.id); - // Try to delete all clusters first - const clusterErrors = await deleteAllClustersOnStaleProject(apiClient, project.id); - allErrors.push(...clusterErrors); + // Phase 1: Delete all workspaces and clusters in parallel across all projects + await Promise.allSettled( + projectsWithIds.map(async (project) => { + console.log(`Cleaning up project: ${project.name} (${project.id})`); + const workspaceErrors = await deleteAllWorkspacesOnStaleProject(apiClient, project.id); + allErrors.push(...workspaceErrors); + const clusterErrors = await deleteAllClustersOnStaleProject(apiClient, project.id); + allErrors.push(...clusterErrors); + }) + ); - // Try to delete the project - try { - await apiClient.deleteGroup({ - params: { - path: { - groupId: project.id, - }, - }, - }); - console.log(`Deleted project: ${project.name} (${project.id})`); - } catch (error) { - const errorStr = String(error); - const errorMessage = `Failed to delete project ${project.name} (${project.id}): ${errorStr}`; - console.error(errorMessage); - allErrors.push(errorMessage); - } - } + // Phase 2: Wait for clusters to terminate, then delete projects in parallel + await Promise.allSettled( + projectsWithIds.map(async (project) => { + // Wait for clusters to be fully deleted (up to 300s) + for (let i = 0; i < 300; i++) { + try { + const remaining = await apiClient + .listClusters({ params: { path: { groupId: project.id } } }) + .then((res) => res.results || []); + if (remaining.length === 0) { + break; + } + await sleep(1000); + } catch { + break; + } + } + try { + await apiClient.deleteGroup({ + params: { path: { groupId: project.id } }, + }); + console.log(`Deleted project: ${project.name} (${project.id})`); + } catch (error) { + const errorMessage = `Failed to delete project ${project.name} (${project.id}): ${String(error)}`; + console.error(errorMessage); + allErrors.push(errorMessage); + } + }) + ); if (allErrors.length > 0) { const errorList = allErrors.map((err, i) => `${i + 1}. ${err}`).join("\n"); diff --git a/src/common/config/userConfig.ts b/src/common/config/userConfig.ts index 6598b464a..07d6d78d9 100644 --- a/src/common/config/userConfig.ts +++ b/src/common/config/userConfig.ts @@ -78,6 +78,8 @@ const ServerConfigSchema = z4.object({ "drop-collection", "delete-many", "drop-index", + "atlas-streams-manage", + "atlas-streams-teardown", ]) .describe( "An array of tool names that require user confirmation before execution. Requires the client to support elicitation." diff --git a/src/common/schemas.ts b/src/common/schemas.ts index 666f8b643..55c9b3aa4 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -1,4 +1,4 @@ -export const previewFeatureValues = ["search", "mcpUI"] as const; +export const previewFeatureValues = ["search", "mcpUI", "streams"] as const; export type PreviewFeature = (typeof previewFeatureValues)[number]; export const monitoringServerFeatureValues = ["health-check", "metrics"] as const; diff --git a/src/elicitation.ts b/src/elicitation.ts index d169c9133..13cb423ab 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -1,6 +1,12 @@ import type { ElicitRequestFormParams } from "@modelcontextprotocol/sdk/types.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +export type ElicitedInputResult = + | { accepted: true; fields: Record } + | { accepted: false; fields?: undefined }; + +const ELICITATION_TIMEOUT_MS = 300_000; // 5 minutes for user interaction + export class Elicitation { private readonly server: McpServer["server"]; constructor({ server }: { server: McpServer["server"] }) { @@ -26,14 +32,56 @@ export class Elicitation { return true; } - const result = await this.server.elicitInput({ - mode: "form", - message, - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - }); + const result = await this.server.elicitInput( + { + mode: "form", + message, + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }, + { timeout: ELICITATION_TIMEOUT_MS } + ); return result.action === "accept" && result.content?.confirmation === "Yes"; } + /** + * Requests structured input from the user via a form. + * Returns the accepted fields, or { accepted: false } if the client doesn't + * support elicitation or the user declined. + * + * @param message - The message/title to display in the form. + * @param schema - A JSON Schema describing the fields to collect. + * @returns The user-provided values keyed by field name, or null if declined/unsupported. + */ + public async requestInput( + message: string, + schema: ElicitRequestFormParams["requestedSchema"] + ): Promise { + if (!this.supportsElicitation()) { + return { accepted: false }; + } + + const result = await this.server.elicitInput( + { + mode: "form", + message, + requestedSchema: schema, + }, + { timeout: ELICITATION_TIMEOUT_MS } + ); + + if (result.action !== "accept" || !result.content) { + return { accepted: false }; + } + + const fields: Record = {}; + for (const [key, value] of Object.entries(result.content)) { + if (typeof value === "string") { + fields[key] = value; + } + } + return { accepted: true, fields }; + } + /** * The schema for the confirmation question. * TODO: In the future would be good to use Zod 4's toJSONSchema() to generate the schema. diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index 98f9228f5..fe05a6141 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -147,6 +147,7 @@ export type TelemetryToolMetadata = | AtlasMetadata | ConnectionMetadata | PerfAdvisorToolMetadata + | StreamsToolMetadata | AutoEmbeddingsUsageMetadata; export type AtlasMetadata = { @@ -169,6 +170,11 @@ export type PerfAdvisorToolMetadata = AtlasMetadata & operations: string[]; }; +export type StreamsToolMetadata = AtlasMetadata & { + action?: string; + resource?: string; +}; + export type AutoEmbeddingsUsageMetadata = ConnectionMetadata & { /** * Indicates which component generated the embeddings. diff --git a/src/tools/atlas/streams/build.ts b/src/tools/atlas/streams/build.ts new file mode 100644 index 000000000..8d6da6c60 --- /dev/null +++ b/src/tools/atlas/streams/build.ts @@ -0,0 +1,817 @@ +import { z } from "zod"; +import { StreamsToolBase } from "./streamsToolBase.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ElicitRequestFormParams } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs } from "../../tool.js"; +import { AtlasArgs } from "../../args.js"; +import { StreamsArgs } from "./streamsArgs.js"; + +const BuildResource = z.enum(["workspace", "connection", "processor", "privatelink"]); + +const ConnectionType = z.enum([ + "Kafka", + "Cluster", + "S3", + "Https", + "AWSKinesisDataStreams", + "AWSLambda", + "SchemaRegistry", + "Sample", +]); + +const CloudProvider = z.enum(["AWS", "AZURE", "GCP"]); + +interface FieldSchema { + title: string; + description: string; +} + +type MissingField = FieldSchema & { key: string }; + +const KAFKA_FIELDS = { + bootstrapServers: { + title: "Bootstrap Servers", + description: "Comma-separated broker addresses (e.g. 'broker1:9092,broker2:9092')", + }, + mechanism: { + title: "Authentication Mechanism", + description: "SASL mechanism: 'PLAIN', 'SCRAM-256', or 'SCRAM-512'", + }, + username: { + title: "Username", + description: "SASL username for Kafka authentication", + }, + password: { + title: "Password", + description: "SASL password for Kafka authentication", + }, + protocol: { + title: "Security Protocol", + description: "Security protocol: 'SASL_SSL', 'SASL_PLAINTEXT', or 'SSL'", + }, +} as const satisfies Record; + +const CLUSTER_FIELDS = { + clusterName: { + title: "Cluster Name", + description: "Name of an Atlas cluster in this project (use `atlas-list-clusters` to see available clusters)", + }, +} as const satisfies Record; + +const AWS_FIELDS = { + roleArn: { + title: "AWS IAM Role ARN", + description: + "IAM role ARN registered in this Atlas project via Cloud Provider Access " + + "(e.g. 'arn:aws:iam::123456789:role/my-role'). " + + "Ask the user for this value — it can be found in: Atlas UI → Project Settings → Cloud Provider Access.", + }, +} as const satisfies Record; + +const SCHEMA_REGISTRY_FIELDS = { + schemaRegistryUrl: { + title: "Schema Registry URL", + description: "Schema Registry endpoint URL (e.g. 'https://schema-registry.example.com')", + }, + username: { + title: "Username", + description: "Username for Schema Registry authentication", + }, + password: { + title: "Password", + description: "Password for Schema Registry authentication", + }, +} as const satisfies Record; + +const HTTPS_FIELDS = { + url: { + title: "Endpoint URL", + description: "HTTPS endpoint URL (e.g. 'https://api.example.com/webhook')", + }, +} as const satisfies Record; + +export class StreamsBuildTool extends StreamsToolBase { + static toolName = "atlas-streams-build"; + static operationType: OperationType = "create"; + + public description = + "Create Atlas Stream Processing resources. " + + "Use this tool for 'set up a Kafka pipeline', 'create a workspace', 'add a connection', or 'deploy a processor'. " + + "Use resource='workspace' to create a new workspace (specify cloud provider, region, and tier). " + + "Use resource='connection' to add a data source or sink to an existing workspace. " + + "Use resource='processor' to deploy a stream processor with an aggregation pipeline. " + + "Use resource='privatelink' to set up private networking. " + + "Typical workflow: create workspace → add connections → deploy processor."; + + public argsShape = { + projectId: AtlasArgs.projectId().describe( + "Atlas project ID. Use atlas-list-projects to find project IDs if not available." + ), + resource: BuildResource.describe( + "What to create. Start with 'workspace', then 'connection', then 'processor'. " + + "Use 'privatelink' only if connections need private networking." + ), + workspaceName: StreamsArgs.workspaceName().describe( + "For 'workspace': the name to create. For others: the existing workspace to add to. " + + "Use `atlas-streams-discover` with action 'list-workspaces' to see existing workspaces." + ), + + // Workspace fields + cloudProvider: CloudProvider.optional().describe("Cloud provider. Required when resource='workspace'."), + region: AtlasArgs.region() + .optional() + .describe( + "Cloud region. Required when resource='workspace'. " + + "Use Atlas region names: AWS examples: 'VIRGINIA_USA', 'OREGON_USA', 'DUBLIN_IRL'. " + + "Azure examples: 'eastus2', 'westeurope'. GCP examples: 'US_CENTRAL1', 'EUROPE_WEST1'." + ), + tier: z + .enum(["SP2", "SP5", "SP10", "SP30", "SP50"]) + .optional() + .describe("Processing tier. Default: SP10. Only for resource='workspace'."), + includeSampleData: z + .boolean() + .optional() + .describe( + "Include the sample_stream_solar connection for testing. Default: true. Only for resource='workspace'." + ), + + // Connection fields + connectionName: StreamsArgs.connectionName() + .optional() + .describe("Connection name. Required when resource='connection'."), + connectionType: ConnectionType.optional().describe( + "Connection type. Required when resource='connection'. " + + "Kafka: needs bootstrapServers, authentication, security config. " + + "Cluster: needs clusterName and dbRoleToExecute. " + + "S3: needs aws.roleArn (must be registered via Atlas Cloud Provider Access). " + + "Https: needs url. " + + "AWSKinesisDataStreams: needs aws.roleArn (must be registered via Atlas Cloud Provider Access). " + + "AWSLambda: needs aws.roleArn (must be registered via Atlas Cloud Provider Access). " + + "SchemaRegistry: needs provider, schemaRegistryUrls, and authentication config. " + + "Sample: provides sample data for testing (no config needed)." + ), + connectionConfig: z + .record(z.unknown()) + .optional() + .describe( + "Type-specific connection configuration. Typically required for non-Sample connections when resource='connection'. " + + "For connectionType='Sample', no config is needed. You may also provide an empty or partial config; missing fields can be filled via elicitation. " + + "Kafka: {bootstrapServers: string (comma-separated), authentication: {mechanism: 'PLAIN'|'SCRAM-256'|'SCRAM-512', username: string, password: string}, security: {protocol: 'SASL_SSL'|'SASL_PLAINTEXT'|'SSL'}}. " + + "Cluster: {clusterName: string, dbRoleToExecute: {role: string, type: 'BUILT_IN'|'CUSTOM'}}. " + + "S3: {aws: {roleArn: string}} (roleArn must be registered via Atlas Cloud Provider Access). " + + "AWSKinesisDataStreams: {aws: {roleArn: string}} (roleArn must be registered via Atlas Cloud Provider Access). " + + "AWSLambda: {aws: {roleArn: string}} (roleArn must be registered via Atlas Cloud Provider Access). " + + "Https: {url: string, headers: Record}. " + + "SchemaRegistry: {provider: 'CONFLUENT', schemaRegistryUrls: [string], schemaRegistryAuthentication: {type: 'USER_INFO'|'SASL_INHERIT', username: string, password: string}}. Use 'USER_INFO' with explicit credentials or 'SASL_INHERIT' to inherit from a Kafka connection." + ), + + // Processor fields + processorName: StreamsArgs.processorName() + .optional() + .describe("Processor name. Required when resource='processor'."), + pipeline: z + .array(z.record(z.unknown())) + .optional() + .describe( + "Aggregation pipeline stages. Required when resource='processor'. " + + "Must start with a $source stage and end with a terminal stage ($merge, $emit, $https, or $externalFunction). " + + "Use $merge to write to Atlas cluster collections: {$merge: {into: {connectionName, db, coll}}}. " + + "Use $emit to write to Kafka or Kinesis sinks: {$emit: {connectionName, topic}}. $emit only works with Kafka/Kinesis connections — do NOT use $emit with Https connections. " + + "Use $https to POST data to an Https connection: {$https: {connectionName}}. " + + "By default $https.onError is 'dlq', which requires a DLQ (see dlq parameter). Set {$https: {connectionName, onError: 'ignore'}} to skip DLQ. " + + "For Kafka $emit with Schema Registry: {$emit: {connectionName, topic, schemaRegistry: {connectionName: '', valueSchema: {type: 'avro', schema: {}, options: {subjectNameStrategy: 'TopicNameStrategy', autoRegisterSchemas: true}}}}}. " + + "Note: valueSchema.type must be lowercase 'avro'. valueSchema.schema (Avro schema definition) is always required even with autoRegisterSchemas. " + + "Kafka/Kinesis $source must include a 'topic'/'stream' field. " + + "$$NOW, $$ROOT, and $$CURRENT are not available in streaming context. " + + "Connections referenced in $source/$merge/$emit/$https must already exist in the workspace." + ), + dlq: z + .object({ + connectionName: z.string().describe("Atlas connection name for DLQ output"), + db: z.string().describe("Database name for DLQ collection"), + coll: z.string().describe("Collection name for DLQ documents"), + }) + .optional() + .describe("Dead letter queue configuration. Recommended for resource='processor'."), + autoStart: z + .boolean() + .optional() + .describe("Start the processor immediately after creation. Default: false. Only for resource='processor'."), + + // PrivateLink fields + privateLinkProvider: CloudProvider.optional().describe( + "Cloud provider for PrivateLink. Required when resource='privatelink'." + ), + privateLinkConfig: z + .record(z.unknown()) + .optional() + .describe( + "Provider-specific PrivateLink configuration. Required when resource='privatelink'. " + + "AWS: {region, vendor, arn, dnsDomain, dnsSubDomain}. " + + "Azure: {region, serviceEndpointId}. " + + "GCP: {region, gcpServiceAttachmentUris}." + ), + }; + + protected async execute(args: ToolArgs): Promise { + switch (args.resource) { + case "workspace": + return this.createWorkspace(args); + case "connection": + return this.createConnection(args); + case "processor": + return this.createProcessor(args); + case "privatelink": + return this.createPrivateLink(args); + default: + return { + content: [{ type: "text", text: `Unknown resource type: ${args.resource as string}` }], + isError: true, + }; + } + } + + private async createWorkspace(args: ToolArgs): Promise { + if (!args.cloudProvider) { + throw new Error("cloudProvider is required when creating a workspace. Choose from: AWS, AZURE, GCP."); + } + if (!args.region) { + throw new Error( + "region is required when creating a workspace (e.g. 'VIRGINIA_USA', 'eastus2', 'US_CENTRAL1')." + ); + } + + const body = { + name: args.workspaceName, + dataProcessRegion: { + cloudProvider: args.cloudProvider, + region: args.region, + }, + streamConfig: { + tier: args.tier ?? "SP10", + }, + }; + + const useSample = args.includeSampleData !== false; + if (useSample) { + await this.apiClient.withStreamSampleConnections({ + params: { path: { groupId: args.projectId } }, + body: body as never, + }); + } else { + await this.apiClient.createStreamWorkspace({ + params: { path: { groupId: args.projectId } }, + body: body as never, + }); + } + + const sampleNote = useSample ? " Includes sample_stream_solar connection for testing." : ""; + + return { + content: [ + { + type: "text", + text: + `Workspace '${args.workspaceName}' created in ${args.cloudProvider}/${args.region} (${args.tier ?? "SP10"}).${sampleNote}\n\n` + + `Next: Add data source/sink connections with \`atlas-streams-build\` resource='connection', ` + + `then deploy a processor with resource='processor'.`, + }, + ], + }; + } + + private async createConnection(args: ToolArgs): Promise { + if (!args.connectionName) { + throw new Error("connectionName is required when adding a connection."); + } + if (!args.connectionType) { + throw new Error( + "connectionType is required. Choose from: Kafka, Cluster, S3, Https, AWSKinesisDataStreams, AWSLambda, SchemaRegistry, Sample." + ); + } + + const config: Record = { ...args.connectionConfig }; + + const missingInfo = await this.normalizeAndValidateConnectionConfig(config, args.connectionType); + if (missingInfo) { + return missingInfo; + } + + const body: Record = { + ...config, + name: args.connectionName, + type: args.connectionType, + }; + + await this.apiClient.createStreamConnection({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName } }, + body: body as never, + }); + + return { + content: [ + { + type: "text", + text: + `Connection '${args.connectionName}' (${args.connectionType}) added to workspace '${args.workspaceName}'.\n\n` + + `Next: Add more connections or deploy a processor with \`atlas-streams-build\` resource='processor'. ` + + `Reference this connection as '${args.connectionName}' in your processor pipeline's $source, $merge, or $emit stages.`, + }, + ], + }; + } + + /** + * Validates and normalizes connectionConfig for the given type. Applies sensible + * defaults, fixes common format mismatches, and uses elicitation to collect + * missing sensitive fields (like passwords) directly from the user when supported. + * + * @returns null if config is valid and ready to send, or a CallToolResult + * describing what information is still needed. + */ + private async normalizeAndValidateConnectionConfig( + config: Record, + connectionType: string + ): Promise { + switch (connectionType) { + case "Kafka": + return this.validateKafkaConfig(config); + case "Cluster": + return this.validateClusterConfig(config); + case "S3": + case "AWSKinesisDataStreams": + case "AWSLambda": + return this.validateAwsConfig(config, connectionType); + case "SchemaRegistry": + return this.validateSchemaRegistryConfig(config); + case "Https": + return this.validateHttpsConfig(config); + default: + return null; + } + } + + private async validateKafkaConfig(config: Record): Promise { + // Normalize bootstrapServers: accept array and convert to comma-separated string + if (Array.isArray(config.bootstrapServers)) { + config.bootstrapServers = (config.bootstrapServers as string[]).join(","); + } + + const auth = config.authentication as Record | undefined; + const security = config.security as Record | undefined; + + const missingFields = StreamsBuildTool.collectMissingFields([ + { key: "bootstrapServers", present: !!config.bootstrapServers, schema: KAFKA_FIELDS.bootstrapServers }, + { key: "mechanism", present: !!auth?.mechanism, schema: KAFKA_FIELDS.mechanism }, + { key: "username", present: !!auth?.username, schema: KAFKA_FIELDS.username }, + { key: "password", present: !!auth?.password, schema: KAFKA_FIELDS.password }, + { key: "protocol", present: !!security?.protocol, schema: KAFKA_FIELDS.protocol }, + ]); + + if (missingFields.length === 0) { + return null; + } + + return this.elicitOrReportMissing("Kafka", config, missingFields, (fields, cfg) => { + if (fields.bootstrapServers) cfg.bootstrapServers = fields.bootstrapServers; + if (!cfg.authentication) cfg.authentication = {}; + const authObj = cfg.authentication as Record; + if (fields.mechanism) authObj.mechanism = fields.mechanism; + if (fields.username) authObj.username = fields.username; + if (fields.password) authObj.password = fields.password; + if (!cfg.security) cfg.security = {}; + const secObj = cfg.security as Record; + if (fields.protocol) secObj.protocol = fields.protocol; + }); + } + + private async validateClusterConfig(config: Record): Promise { + const missingFields = StreamsBuildTool.collectMissingFields([ + { key: "clusterName", present: !!config.clusterName, schema: CLUSTER_FIELDS.clusterName }, + ]); + + // dbRoleToExecute is a config choice, not user-specific data — safe to default + if (!config.dbRoleToExecute) { + config.dbRoleToExecute = { role: "readWriteAnyDatabase", type: "BUILT_IN" }; + } + + if (missingFields.length === 0) { + return null; + } + + return this.elicitOrReportMissing("Cluster", config, missingFields, (fields, cfg) => { + if (fields.clusterName) cfg.clusterName = fields.clusterName; + }); + } + + private async validateAwsConfig( + config: Record, + connectionType: string + ): Promise { + const aws = config.aws as Record | undefined; + + const missingFields = StreamsBuildTool.collectMissingFields([ + { key: "roleArn", present: !!aws?.roleArn, schema: AWS_FIELDS.roleArn }, + ]); + + if (missingFields.length === 0) { + return null; + } + + return this.elicitOrReportMissing( + connectionType, + config, + missingFields, + (fields, cfg) => { + if (fields.roleArn) { + if (!cfg.aws) cfg.aws = {}; + (cfg.aws as Record).roleArn = fields.roleArn; + } + }, + `Note: The IAM role ARN must first be registered in the Atlas project via Cloud Provider Access.\n` + + `To find available ARNs: Atlas UI → Project Settings → Cloud Provider Access.\n` + + `To register a new one: Atlas UI → Project Settings → Cloud Provider Access → Authorize an AWS IAM role.` + ); + } + + private async validateSchemaRegistryConfig(config: Record): Promise { + // Normalize common alternative key names for schemaRegistryUrls + if (!config.schemaRegistryUrls) { + const alt = config.url || config.urls || config.endpoint || config.schemaRegistryUrl; + if (alt) { + config.schemaRegistryUrls = Array.isArray(alt) ? alt : [alt]; + delete config.url; + delete config.urls; + delete config.endpoint; + delete config.schemaRegistryUrl; + } + } + + // Normalize schemaRegistryUrls: accept a single string and wrap in array + if (typeof config.schemaRegistryUrls === "string") { + config.schemaRegistryUrls = [config.schemaRegistryUrls]; + } + + // Normalize common alternative key names for authentication + if (!config.schemaRegistryAuthentication && (config.username || config.authentication)) { + const authSource = (config.authentication as Record) || {}; + config.schemaRegistryAuthentication = { + type: "USER_INFO", + username: config.username || authSource.username, + password: config.password || authSource.password, + }; + delete config.username; + delete config.password; + delete config.authentication; + } + + // Default provider to CONFLUENT — currently the only supported value + if (!config.provider) { + config.provider = "CONFLUENT"; + } + + // Default auth type to USER_INFO when credentials are provided + if (!config.schemaRegistryAuthentication) { + config.schemaRegistryAuthentication = {}; + } + const auth = config.schemaRegistryAuthentication as Record; + if (!auth.type) { + auth.type = "USER_INFO"; + } + + const requiresCredentials = auth.type !== "SASL_INHERIT"; + const missingFields = StreamsBuildTool.collectMissingFields([ + { + key: "schemaRegistryUrl", + present: Array.isArray(config.schemaRegistryUrls) && config.schemaRegistryUrls.length > 0, + schema: SCHEMA_REGISTRY_FIELDS.schemaRegistryUrl, + }, + { + key: "username", + present: !requiresCredentials || !!auth.username, + schema: SCHEMA_REGISTRY_FIELDS.username, + }, + { + key: "password", + present: !requiresCredentials || !!auth.password, + schema: SCHEMA_REGISTRY_FIELDS.password, + }, + ]); + + if (missingFields.length === 0) { + return null; + } + + return this.elicitOrReportMissing("SchemaRegistry", config, missingFields, (fields, cfg) => { + if (fields.schemaRegistryUrl) { + cfg.schemaRegistryUrls = [fields.schemaRegistryUrl]; + } + const authObj = cfg.schemaRegistryAuthentication as Record; + if (fields.username) authObj.username = fields.username; + if (fields.password) authObj.password = fields.password; + }); + } + + private async validateHttpsConfig(config: Record): Promise { + const missingFields = StreamsBuildTool.collectMissingFields([ + { key: "url", present: !!config.url, schema: HTTPS_FIELDS.url }, + ]); + + if (missingFields.length === 0) { + return null; + } + + return this.elicitOrReportMissing("Https", config, missingFields, (fields, cfg) => { + if (fields.url) cfg.url = fields.url; + }); + } + + // --- Shared elicitation helpers --- + + /** + * Attempts to collect all missing required fields via elicitation. If the + * client supports it, shows a single form with every missing field. If not + * (or the user declines), returns a structured response listing what's needed. + */ + private async elicitOrReportMissing( + connectionType: string, + config: Record, + missingFields: MissingField[], + applyFields: (fields: Record, config: Record) => void, + additionalNote?: string + ): Promise { + const schema = StreamsBuildTool.buildElicitationSchema(connectionType, missingFields); + + const elicited = await this.elicitation.requestInput( + `The following information is required to create the ${connectionType} connection.`, + schema + ); + + if (elicited.accepted) { + applyFields(elicited.fields, config); + + // Re-check: did the user leave any fields empty in the form? + const stillMissing = missingFields.filter((f) => !elicited.fields[f.key]); + if (stillMissing.length > 0) { + return StreamsBuildTool.missingFieldsResponse(connectionType, stillMissing, additionalNote); + } + return null; + } + + return StreamsBuildTool.missingFieldsResponse(connectionType, missingFields, additionalNote); + } + + private static collectMissingFields( + checks: { key: string; present: boolean; schema: FieldSchema }[] + ): MissingField[] { + return checks.filter((c) => !c.present).map((c) => ({ key: c.key, ...c.schema })); + } + + private static buildElicitationSchema( + _connectionType: string, + missingFields: MissingField[] + ): ElicitRequestFormParams["requestedSchema"] { + const properties: Record = {}; + for (const field of missingFields) { + properties[field.key] = { + type: "string" as const, + title: field.title, + description: field.description, + }; + } + return { + type: "object" as const, + properties, + required: missingFields.map((f) => f.key), + }; + } + + private static missingFieldsResponse( + connectionType: string, + missingFields: MissingField[], + additionalNote?: string + ): CallToolResult { + const list = missingFields.map((f) => ` - ${f.title}: ${f.description}`).join("\n"); + const note = additionalNote ? `\n\n${additionalNote}` : ""; + return { + content: [ + { + type: "text", + text: + `Cannot create ${connectionType} connection — the following required information is missing:\n${list}\n\n` + + `Please ask the user to provide these values and retry.${note}`, + }, + ], + isError: true, + }; + } + + private static validatePipelineStructure(pipeline: Record[]): CallToolResult | null { + const TERMINAL_STAGES = new Set(["$merge", "$emit", "$https", "$externalFunction"]); + + const firstStage = pipeline[0]; + const firstStageKey = firstStage ? Object.keys(firstStage)[0] : undefined; + if (firstStageKey !== "$source") { + return { + content: [ + { + type: "text", + text: + `Invalid pipeline: first stage must be \`$source\`, but found \`${firstStageKey}\`.\n\n` + + `A streaming pipeline must start with $source to define the input data stream. ` + + `Example: {$source: {connectionName: "myConnection", topic: "myTopic"}}`, + }, + ], + isError: true, + }; + } + + const lastStage = pipeline[pipeline.length - 1]; + const lastStageKey = lastStage ? Object.keys(lastStage)[0] : undefined; + if (!lastStageKey || !TERMINAL_STAGES.has(lastStageKey)) { + return { + content: [ + { + type: "text", + text: + `Invalid pipeline: last stage must be a terminal stage (\`$merge\`, \`$emit\`, \`$https\`, or \`$externalFunction\`), but found \`${lastStageKey}\`.\n\n` + + `Use $merge to write to Atlas clusters: {$merge: {into: {connectionName, db, coll}}}.\n` + + `Use $emit to write to Kafka/Kinesis/external sinks: {$emit: {connectionName, topic}}.`, + }, + ], + isError: true, + }; + } + + const pipelineStr = JSON.stringify(pipeline); + const unsupportedVars = ["$$NOW", "$$ROOT", "$$CURRENT"].filter((v) => pipelineStr.includes(v)); + if (unsupportedVars.length > 0) { + return { + content: [ + { + type: "text", + text: + `Warning: pipeline contains ${unsupportedVars.join(", ")} which ${unsupportedVars.length === 1 ? "is" : "are"} not available in streaming context.\n\n` + + `These system variables are not supported in Atlas Stream Processing pipelines. ` + + `Remove or replace them before deploying.`, + }, + ], + isError: true, + }; + } + + return null; + } + + private async validatePipelineConnections( + projectId: string, + workspaceName: string, + pipeline: Record[], + dlq?: { connectionName: string; db: string; coll: string } + ): Promise { + const referencedNames = StreamsToolBase.extractConnectionNames(pipeline); + if (dlq?.connectionName) referencedNames.add(dlq.connectionName); + if (referencedNames.size === 0) return null; + + let availableNames: Set; + try { + const data = await this.apiClient.listStreamConnections({ + params: { + path: { groupId: projectId, tenantName: workspaceName }, + query: { itemsPerPage: 100, pageNum: 1 }, + }, + }); + availableNames = new Set((data?.results ?? []).map((c) => String((c as Record).name))); + } catch { + return null; // Soft check — skip if we can't list connections + } + + const missingNames = [...referencedNames].filter((n) => !availableNames.has(n)); + if (missingNames.length === 0) return null; + + const availableList = + availableNames.size > 0 + ? [...availableNames].map((n) => ` - ${n}`).join("\n") + : " (no connections found)"; + + return { + content: [ + { + type: "text", + text: + `Cannot create processor — the pipeline references connection(s) that do not exist in workspace '${workspaceName}':\n` + + ` Missing: ${missingNames.join(", ")}\n\n` + + `Available connections:\n${availableList}\n\n` + + `Add the missing connection(s) first with \`atlas-streams-build\` resource='connection', then retry.`, + }, + ], + isError: true, + }; + } + + private async createProcessor(args: ToolArgs): Promise { + if (!args.processorName) { + throw new Error("processorName is required when deploying a processor."); + } + if (!args.pipeline || args.pipeline.length === 0) { + throw new Error( + "pipeline is required. Provide an array of aggregation stages starting with $source and ending with a terminal stage ($merge, $emit, $https, or $externalFunction)." + ); + } + + const structureError = StreamsBuildTool.validatePipelineStructure(args.pipeline); + if (structureError) return structureError; + + const connectionError = await this.validatePipelineConnections( + args.projectId, + args.workspaceName, + args.pipeline, + args.dlq + ); + if (connectionError) return connectionError; + + const body = { + name: args.processorName, + pipeline: args.pipeline, + options: args.dlq ? { dlq: args.dlq } : undefined, + }; + + await this.apiClient.createStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName } }, + body: body as never, + }); + + let startMessage = "Processor created in STOPPED state."; + if (args.autoStart) { + await this.apiClient.startStreamProcessor({ + params: { + path: { + groupId: args.projectId, + tenantName: args.workspaceName, + processorName: args.processorName, + }, + }, + }); + startMessage = "Processor created and started."; + } + + const dlqNote = args.dlq + ? ` DLQ configured: ${args.dlq.db}.${args.dlq.coll} via '${args.dlq.connectionName}'.` + : " Consider adding a DLQ for production use."; + + const billingNote = args.autoStart + ? `\n\nNote: Billing for stream processing usage is now active for this processor. ` + + `Use \`atlas-streams-manage\` with action 'stop-processor' to stop billing.` + : ""; + + return { + content: [ + { + type: "text", + text: + `${startMessage} Processor '${args.processorName}' deployed in workspace '${args.workspaceName}'.${dlqNote}\n\n` + + (args.autoStart + ? `Use \`atlas-streams-discover\` with action 'diagnose-processor' to monitor health.` + : `Use \`atlas-streams-manage\` with action 'start-processor' to begin processing.`) + + billingNote, + }, + ], + }; + } + + private async createPrivateLink(args: ToolArgs): Promise { + if (!args.privateLinkProvider) { + throw new Error("privateLinkProvider is required. Choose from: AWS, AZURE, GCP."); + } + if (!args.privateLinkConfig) { + throw new Error( + "privateLinkConfig is required. Provide provider-specific configuration " + + "(AWS: {region, vendor, arn, dnsDomain, dnsSubDomain}, Azure: {region, serviceEndpointId}, GCP: {region, gcpServiceAttachmentUris})." + ); + } + + const body: Record = { + ...args.privateLinkConfig, + provider: args.privateLinkProvider, + }; + + await this.apiClient.createPrivateLinkConnection({ + params: { path: { groupId: args.projectId } }, + body: body as never, + }); + + return { + content: [ + { + type: "text", + text: + `PrivateLink connection created for ${args.privateLinkProvider}. ` + + `It may take a few minutes to become active. ` + + `Use \`atlas-streams-discover\` with action 'get-networking' to check status.\n\n` + + `Once active, create connections with networking.access.type='PRIVATE_LINK' to use it.`, + }, + ], + }; + } +} diff --git a/src/tools/atlas/streams/discover.ts b/src/tools/atlas/streams/discover.ts new file mode 100644 index 000000000..4ac9af77f --- /dev/null +++ b/src/tools/atlas/streams/discover.ts @@ -0,0 +1,586 @@ +import { z } from "zod"; +import { gunzip } from "node:zlib"; +import { promisify } from "node:util"; +import { StreamsToolBase } from "./streamsToolBase.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs } from "../../tool.js"; +import { formatUntrustedData } from "../../tool.js"; +import { AtlasArgs } from "../../args.js"; +import { StreamsArgs } from "./streamsArgs.js"; + +const gunzipAsync = promisify(gunzip); + +const DiscoverAction = z.enum([ + "list-workspaces", + "inspect-workspace", + "list-connections", + "inspect-connection", + "list-processors", + "inspect-processor", + "diagnose-processor", + "get-logs", + "get-networking", +]); + +const ResponseFormat = z.enum(["concise", "detailed"]); + +export class StreamsDiscoverTool extends StreamsToolBase { + static toolName = "atlas-streams-discover"; + static operationType: OperationType = "read"; + + public description = + "Discover and inspect Atlas Stream Processing resources. " + + "Also use for 'why is my processor failing', 'what workspaces do I have', 'show processor stats', or 'check processor health'. " + + "Use 'list-workspaces' to see all workspaces in a project. " + + "Use inspect actions for details on a specific resource. " + + "Use 'diagnose-processor' for a combined health report including state, stats, connection health, and recent errors. " + + "Use 'get-logs' for operational or audit logs and 'get-networking' for PrivateLink and account details."; + + public argsShape = { + projectId: AtlasArgs.projectId().describe( + "Atlas project ID. Use atlas-list-projects to find project IDs if not available." + ), + action: DiscoverAction.describe( + "What to look up. Start with 'list-workspaces' to see available workspaces, " + + "then use inspect actions for details or 'diagnose-processor' for a health report." + ), + workspaceName: StreamsArgs.workspaceName() + .optional() + .describe("Workspace name. Required for all actions except 'list-workspaces' and 'get-networking'."), + resourceName: z + .string() + .optional() + .describe( + "Connection or processor name. Required for 'inspect-connection', 'inspect-processor', and 'diagnose-processor'. Optional for 'get-logs' to filter logs by processor." + ), + responseFormat: ResponseFormat.optional().describe( + "Response detail level. 'concise' returns names and states only. " + + "'detailed' returns full configuration and stats. " + + "Default: 'concise' for list actions, 'detailed' for inspect/diagnose." + ), + cloudProvider: z + .string() + .optional() + .describe( + "Cloud provider (AWS, AZURE, GCP). Only for 'get-networking': returns account details for the specified provider." + ), + region: z + .string() + .optional() + .describe("Cloud region. Only for 'get-networking': returns account details for the specified region."), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Max results per page for list actions. Default: 20."), + pageNum: z.number().int().min(1).optional().describe("Page number for list actions. Default: 1."), + logType: z + .enum(["audit", "operational"]) + .optional() + .describe( + "Type of logs to retrieve. 'operational' returns runtime logs (errors, Kafka failures, schema issues). " + + "'audit' returns lifecycle events (start/stop). Default: 'operational'. Only for 'get-logs' action." + ), + }; + + protected async execute({ + projectId, + action, + workspaceName, + resourceName, + responseFormat, + cloudProvider, + region, + limit, + pageNum, + logType, + }: ToolArgs): Promise { + switch (action) { + case "list-workspaces": + return this.listWorkspaces(projectId, responseFormat, limit, pageNum); + case "inspect-workspace": + return this.inspectWorkspace(projectId, this.requireWorkspaceName(workspaceName), responseFormat); + case "list-connections": + return this.listConnections( + projectId, + this.requireWorkspaceName(workspaceName), + responseFormat, + limit, + pageNum + ); + case "inspect-connection": + return this.inspectConnection( + projectId, + this.requireWorkspaceName(workspaceName), + this.requireResourceName(resourceName, "connection") + ); + case "list-processors": + return this.listProcessors( + projectId, + this.requireWorkspaceName(workspaceName), + responseFormat, + limit, + pageNum + ); + case "inspect-processor": + return this.inspectProcessor( + projectId, + this.requireWorkspaceName(workspaceName), + this.requireResourceName(resourceName, "processor") + ); + case "diagnose-processor": + return this.diagnoseProcessor( + projectId, + this.requireWorkspaceName(workspaceName), + this.requireResourceName(resourceName, "processor") + ); + case "get-logs": + return this.getLogs(projectId, this.requireWorkspaceName(workspaceName), resourceName, logType); + case "get-networking": + return this.getNetworking(projectId, cloudProvider, region); + default: + return { + content: [{ type: "text", text: `Unknown action: ${action as string}` }], + isError: true, + }; + } + } + + private requireWorkspaceName(workspaceName: string | undefined): string { + if (!workspaceName) { + throw new Error( + "workspaceName is required for this action. Use action 'list-workspaces' to see available workspaces." + ); + } + return workspaceName; + } + + private requireResourceName(resourceName: string | undefined, resourceType: string): string { + if (!resourceName) { + throw new Error( + `resourceName is required to inspect a ${resourceType}. ` + + `Use 'list-${resourceType}s' action to see available ${resourceType}s.` + ); + } + return resourceName; + } + + private async listWorkspaces( + projectId: string, + responseFormat: string | undefined, + limit: number | undefined, + pageNum: number | undefined + ): Promise { + const data = await this.apiClient.listStreamWorkspaces({ + params: { path: { groupId: projectId }, query: { itemsPerPage: limit ?? 20, pageNum: pageNum ?? 1 } }, + }); + + if (!data?.results?.length) { + return { + content: [ + { + type: "text", + text: "No Stream Processing workspaces found in this project. Use `atlas-streams-build` with resource='workspace' to create one.", + }, + ], + }; + } + + const format = responseFormat ?? "concise"; + const workspaces = + format === "concise" + ? data.results.map((w) => ({ + name: w.name, + region: w.dataProcessRegion + ? `${w.dataProcessRegion.cloudProvider}/${w.dataProcessRegion.region}` + : "unknown", + tier: w.streamConfig?.tier ?? "unknown", + maxTier: w.streamConfig?.maxTierSize ?? "unknown", + })) + : data.results; + + return { + content: formatUntrustedData( + `Found ${data.results.length} workspace(s) (total: ${data.totalCount ?? data.results.length}):`, + JSON.stringify(workspaces, null, 2) + ), + }; + } + + private async inspectWorkspace( + projectId: string, + workspaceName: string, + responseFormat: string | undefined + ): Promise { + const data = await this.apiClient.getStreamWorkspace({ + params: { path: { groupId: projectId, tenantName: workspaceName }, query: { includeConnections: true } }, + }); + if (!data) { + throw new Error(`Workspace '${workspaceName}' not found.`); + } + + const format = responseFormat ?? "detailed"; + const output = + format === "concise" + ? { + name: data.name, + region: data.dataProcessRegion + ? `${data.dataProcessRegion.cloudProvider}/${data.dataProcessRegion.region}` + : "unknown", + tier: data.streamConfig?.tier ?? "unknown", + maxTier: data.streamConfig?.maxTierSize ?? "unknown", + connectionCount: data.connections?.length ?? 0, + } + : data; + + return { + content: formatUntrustedData(`Workspace '${workspaceName}' details:`, JSON.stringify(output, null, 2)), + }; + } + + private async listConnections( + projectId: string, + workspaceName: string, + responseFormat: string | undefined, + limit: number | undefined, + pageNum: number | undefined + ): Promise { + const data = await this.apiClient.listStreamConnections({ + params: { + path: { groupId: projectId, tenantName: workspaceName }, + query: { itemsPerPage: limit ?? 20, pageNum: pageNum ?? 1 }, + }, + }); + + if (!data?.results?.length) { + return { + content: [ + { + type: "text", + text: `No connections found in workspace '${workspaceName}'. Use \`atlas-streams-build\` with resource='connection' to add one.`, + }, + ], + }; + } + + const format = responseFormat ?? "concise"; + const connections = + format === "concise" + ? data.results.map((c) => { + const conn = c as Record; + return { + name: conn.name, + type: conn.type, + state: conn.state, + }; + }) + : data.results; + + return { + content: formatUntrustedData( + `Found ${data.results.length} connection(s) in workspace '${workspaceName}':`, + JSON.stringify(connections, null, 2) + ), + }; + } + + private async inspectConnection( + projectId: string, + workspaceName: string, + connectionName: string + ): Promise { + const data = (await this.apiClient.getStreamConnection({ + params: { path: { groupId: projectId, tenantName: workspaceName, connectionName } }, + })) as Record; + + let header = `Connection '${connectionName}' in workspace '${workspaceName}':`; + + if (data.type === "Cluster" && typeof data.clusterName === "string" && data.clusterName !== data.name) { + header += + `\n\nNote: This connection is named '${String(data.name)}' but targets cluster '${data.clusterName}'. ` + + `Use the connection name '${String(data.name)}' (not the cluster name) when referencing it in pipeline stages.`; + } + + return { + content: formatUntrustedData(header, JSON.stringify(data, null, 2)), + }; + } + + private async listProcessors( + projectId: string, + workspaceName: string, + responseFormat: string | undefined, + limit: number | undefined, + pageNum: number | undefined + ): Promise { + const data = await this.apiClient.getStreamProcessors({ + params: { + path: { groupId: projectId, tenantName: workspaceName }, + query: { itemsPerPage: limit ?? 20, pageNum: pageNum ?? 1 }, + }, + }); + + if (!data?.results?.length) { + return { + content: [ + { + type: "text", + text: `No processors found in workspace '${workspaceName}'. Use \`atlas-streams-build\` with resource='processor' to deploy one.`, + }, + ], + }; + } + + const format = responseFormat ?? "concise"; + const processors = + format === "concise" + ? data.results.map((p) => ({ + name: p.name, + state: p.state, + tier: p.tier, + })) + : data.results; + + return { + content: formatUntrustedData( + `Found ${data.results.length} processor(s) in workspace '${workspaceName}':`, + JSON.stringify(processors, null, 2) + ), + }; + } + + private async inspectProcessor( + projectId: string, + workspaceName: string, + processorName: string + ): Promise { + const data = await this.apiClient.getStreamProcessor({ + params: { path: { groupId: projectId, tenantName: workspaceName, processorName } }, + }); + return { + content: formatUntrustedData( + `Processor '${processorName}' in workspace '${workspaceName}':`, + JSON.stringify(data, null, 2) + ), + }; + } + + private async diagnoseProcessor( + projectId: string, + workspaceName: string, + processorName: string + ): Promise { + const [processorResult, connectionsResult] = await Promise.allSettled([ + this.apiClient.getStreamProcessor({ + params: { path: { groupId: projectId, tenantName: workspaceName, processorName } }, + }), + this.apiClient.listStreamConnections({ + params: { path: { groupId: projectId, tenantName: workspaceName } }, + }), + ]); + + const sections: string[] = []; + + // Processor state and stats + if (processorResult.status === "fulfilled" && processorResult.value) { + const proc = processorResult.value; + sections.push( + `## Processor State\n- Name: ${proc.name}\n- State: ${proc.state}\n- Tier: ${proc.tier ?? "default"}` + ); + + if (proc.stats && Object.keys(proc.stats).length > 0) { + sections.push(`## Processor Stats\n${JSON.stringify(proc.stats, null, 2)}`); + + // Add health interpretation based on stats + const stats = proc.stats as Record; + const inputCount = Number(stats.inputMessageCount ?? 0); + const outputCount = Number(stats.outputMessageCount ?? 0); + const dlqCount = Number(stats.dlqMessageCount ?? 0); + + if (inputCount > 0 && outputCount === 0 && dlqCount > 0) { + sections.push( + `## Health Warning\n` + + `All ${dlqCount} input messages went to DLQ (0 successful outputs). ` + + `This typically indicates a schema mismatch, serialization error, or sink configuration problem. ` + + `Query the DLQ collection to inspect error details.` + ); + } else if (inputCount > 0 && dlqCount > 0) { + const dlqRatio = Math.round((dlqCount / inputCount) * 100); + if (dlqRatio > 50) { + sections.push( + `## Health Warning\n` + + `${dlqRatio}% of messages going to DLQ. Check DLQ collection for error patterns.` + ); + } + } + } + + if (proc.options?.dlq) { + sections.push( + `## Dead Letter Queue Config\n- Connection: ${proc.options.dlq.connectionName}\n- Database: ${proc.options.dlq.db}\n- Collection: ${proc.options.dlq.coll}` + ); + } + + if (proc.pipeline) { + sections.push(`## Pipeline\n${JSON.stringify(proc.pipeline, null, 2)}`); + } + } else { + sections.push( + `## Processor State\nError fetching processor: ${processorResult.status === "rejected" ? String(processorResult.reason) : "No data returned"}` + ); + } + + // Connection health + if (connectionsResult.status === "fulfilled" && connectionsResult.value?.results) { + const connections = connectionsResult.value.results; + const summary = connections + .map((c) => { + const conn = c as Record; + return `- ${String(conn.name)} (${String(conn.type)}): ${String(conn.state)}`; + }) + .join("\n"); + sections.push(`## Connection Health\n${summary}`); + } + + // Actionable guidance + const proc = processorResult.status === "fulfilled" ? processorResult.value : undefined; + if (proc?.state === "FAILED") { + sections.push( + `## Recommended Actions\n- Check the Dead Letter Queue for failed documents\n- Use \`atlas-streams-manage\` with action 'modify-processor' to fix pipeline issues (processor must be stopped)\n- Use \`atlas-streams-manage\` with action 'start-processor' and resumeFromCheckpoint=false to restart from the beginning` + ); + } else if (proc?.state === "STOPPED") { + sections.push( + `## Recommended Actions\n- Use \`atlas-streams-manage\` with action 'start-processor' to resume processing` + ); + } + + return { + content: formatUntrustedData( + `Diagnostic report for processor '${processorName}' in workspace '${workspaceName}':`, + sections.join("\n\n") + ), + }; + } + + private async getLogs( + projectId: string, + workspaceName: string, + processorName?: string, + logType?: string + ): Promise { + const resolvedLogType = logType ?? "operational"; + + const data = + resolvedLogType === "operational" + ? await this.apiClient.downloadOperationalLogs({ + params: { + path: { groupId: projectId, tenantName: workspaceName }, + query: processorName + ? ({ spName: processorName } as { spName?: string; startDate?: number; endDate?: number }) + : {}, + }, + headers: { + Accept: "application/vnd.atlas.2025-03-12+gzip", + }, + parseAs: "arrayBuffer", + }) + : await this.apiClient.downloadAuditLogs({ + params: { + path: { groupId: projectId, tenantName: workspaceName }, + query: processorName + ? ({ spName: processorName } as { spName?: string; startDate?: number; endDate?: number }) + : {}, + }, + headers: { + Accept: "application/vnd.atlas.2023-02-01+gzip", + }, + parseAs: "arrayBuffer", + }); + + if (!data) { + return { + content: [{ type: "text", text: `No logs available for workspace '${workspaceName}'.` }], + }; + } + + try { + const buffer = Buffer.from(data as unknown as ArrayBuffer); + const decompressed = (await gunzipAsync(buffer)).toString("utf-8"); + + // Limit output to avoid overwhelming the context + const lines = decompressed.split("\n").filter((line) => line.trim()); + const maxLines = 100; + const truncated = lines.length > maxLines; + const output = lines.slice(-maxLines).join("\n"); + + const header = processorName + ? `${resolvedLogType === "operational" ? "Operational" : "Audit"} logs for processor '${processorName}' in workspace '${workspaceName}'` + : `${resolvedLogType === "operational" ? "Operational" : "Audit"} logs for workspace '${workspaceName}'`; + const truncationNote = truncated ? ` (showing last ${maxLines} of ${lines.length} lines)` : ""; + + return { + content: formatUntrustedData(`${header}${truncationNote}:`, output), + }; + } catch { + return { + content: [ + { + type: "text", + text: + `Could not decompress logs for workspace '${workspaceName}'. ` + + `Try downloading manually:\n atlas streams download-logs --projectId ${projectId} --workspace ${workspaceName}`, + }, + ], + }; + } + } + + private async getNetworking( + projectId: string, + cloudProvider: string | undefined, + region: string | undefined + ): Promise { + const [privateLinkResult] = await Promise.allSettled([ + this.apiClient.listPrivateLinkConnections({ + params: { path: { groupId: projectId } }, + }), + ]); + + const sections: string[] = []; + + if (cloudProvider && region) { + try { + const accountDetails = await this.apiClient.getAccountDetails({ + params: { + path: { groupId: projectId }, + query: { cloudProvider, regionName: region }, + }, + }); + sections.push( + `## Account Details (${cloudProvider}/${region})\n${JSON.stringify(accountDetails, null, 2)}` + ); + } catch { + sections.push(`## Account Details\nCould not fetch account details for ${cloudProvider}/${region}.`); + } + } + + if (privateLinkResult.status === "fulfilled" && privateLinkResult.value?.results?.length) { + const pls = privateLinkResult.value.results.map((pl) => ({ + id: pl._id, + provider: pl.provider, + region: pl.region, + state: pl.state, + vendor: pl.vendor, + })); + sections.push(`## PrivateLink Connections\n${JSON.stringify(pls, null, 2)}`); + } else { + sections.push("## PrivateLink Connections\nNo PrivateLink connections found."); + } + + return { + content: formatUntrustedData("Streams networking details:", sections.join("\n\n")), + }; + } +} diff --git a/src/tools/atlas/streams/manage.ts b/src/tools/atlas/streams/manage.ts new file mode 100644 index 000000000..075395a05 --- /dev/null +++ b/src/tools/atlas/streams/manage.ts @@ -0,0 +1,458 @@ +import { z } from "zod"; +import { StreamsToolBase } from "./streamsToolBase.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs } from "../../tool.js"; +import { AtlasArgs } from "../../args.js"; +import { StreamsArgs } from "./streamsArgs.js"; + +const ManageAction = z.enum([ + "start-processor", + "stop-processor", + "modify-processor", + "update-workspace", + "update-connection", + "accept-peering", + "reject-peering", +]); + +export class StreamsManageTool extends StreamsToolBase { + static toolName = "atlas-streams-manage"; + static operationType: OperationType = "update"; + + public description = + "Manage Atlas Stream Processing resources: start/stop processors, modify pipelines, update configurations. " + + "Also use for 'change the pipeline', 'scale up my processor', or 'update my workspace tier'. " + + "Common workflow: action='stop-processor' → action='modify-processor' → action='start-processor'. " + + "Use `atlas-streams-discover` with action 'inspect-processor' to check state before managing."; + + public argsShape = { + projectId: AtlasArgs.projectId().describe( + "Atlas project ID. Use atlas-list-projects to find project IDs if not available." + ), + workspaceName: StreamsArgs.workspaceName().describe("Workspace name containing the resource to manage."), + action: ManageAction.describe( + "Action to perform. Processor must be stopped before 'modify-processor'. " + + "Use 'start-processor' to begin or resume processing, 'stop-processor' to pause." + ), + resourceName: z + .string() + .optional() + .describe("Processor or connection name. Required for processor and connection actions."), + + // start-processor options + tier: z + .enum(["SP2", "SP5", "SP10", "SP30", "SP50"]) + .optional() + .describe( + "Override processing tier for this run. " + + "Must not exceed the workspace's max tier. Use `atlas-streams-discover` action='inspect-workspace' to check. " + + "Only for 'start-processor'." + ), + resumeFromCheckpoint: z + .boolean() + .optional() + .describe( + "Resume from last checkpoint on start. Default: true. " + + "Set false to reprocess from beginning (drops accumulated window state). Only for 'start-processor'." + ), + startAtOperationTime: z + .string() + .optional() + .describe("ISO 8601 timestamp to resume from. Only for 'start-processor'."), + + // modify-processor options + pipeline: z + .array(z.record(z.unknown())) + .optional() + .describe( + "New aggregation pipeline. Only for 'modify-processor'. Processor must be stopped first. " + + "If changing a window stage interval, the processor must be restarted with resumeFromCheckpoint=false." + ), + dlq: z + .object({ + connectionName: z.string(), + db: z.string(), + coll: z.string(), + }) + .optional() + .describe("New DLQ configuration. Only for 'modify-processor'."), + newName: z.string().optional().describe("Rename processor. Only for 'modify-processor'."), + + // update-workspace options + newRegion: z + .string() + .optional() + .describe( + "New region for workspace. Only for 'update-workspace'. Use Atlas region names (e.g. AWS: 'VIRGINIA_USA', Azure: 'eastus2', GCP: 'US_CENTRAL1')." + ), + newTier: z + .enum(["SP2", "SP5", "SP10", "SP30", "SP50"]) + .optional() + .describe("New default tier for workspace. Only for 'update-workspace'."), + + // update-connection options + connectionConfig: z + .record(z.unknown()) + .optional() + .describe( + "Updated connection configuration. Only for 'update-connection'. " + + "Note: networking config cannot be modified after creation — to change networking, delete and recreate the connection." + ), + + // peering options + peeringId: z + .string() + .optional() + .describe("VPC peering connection ID. Required for 'accept-peering' and 'reject-peering'."), + requesterAccountId: z + .string() + .optional() + .describe("AWS account ID of the peering requester. Required for 'accept-peering'."), + requesterVpcId: z + .string() + .optional() + .describe("VPC ID of the peering requester. Required for 'accept-peering'."), + }; + + protected async execute(args: ToolArgs): Promise { + switch (args.action) { + case "start-processor": + return this.startProcessor(args); + case "stop-processor": + return this.stopProcessor(args); + case "modify-processor": + return this.modifyProcessor(args); + case "update-workspace": + return this.updateWorkspace(args); + case "update-connection": + return this.updateConnection(args); + case "accept-peering": + return this.acceptPeering(args); + case "reject-peering": + return this.rejectPeering(args); + default: + return { + content: [{ type: "text", text: `Unknown action: ${args.action as string}` }], + isError: true, + }; + } + } + + protected override getConfirmationMessage(args: ToolArgs): string { + switch (args.action) { + case "start-processor": { + const name = this.requireResourceName(args.resourceName, "start-processor"); + const checkpointWarning = + args.resumeFromCheckpoint === false + ? ` WARNING: resumeFromCheckpoint is false — all accumulated window state will be permanently lost.` + : ""; + return ( + `You are about to start processor '${name}' in workspace '${args.workspaceName}'. ` + + `Starting a processor will begin billing for stream processing usage based on the workspace tier.${checkpointWarning} Proceed?` + ); + } + case "stop-processor": { + const name = this.requireResourceName(args.resourceName, "stop-processor"); + return `You are about to stop processor '${name}' in workspace '${args.workspaceName}'. In-flight data will complete processing. Proceed?`; + } + case "modify-processor": { + const name = this.requireResourceName(args.resourceName, "modify-processor"); + return `You are about to modify processor '${name}' in workspace '${args.workspaceName}'. This may affect pipeline behavior. Proceed?`; + } + case "update-workspace": + return `You are about to update workspace '${args.workspaceName}'. Proceed?`; + case "update-connection": { + const name = this.requireResourceName(args.resourceName, "update-connection"); + return `You are about to update connection '${name}' in workspace '${args.workspaceName}'. Proceed?`; + } + case "accept-peering": { + if (!args.peeringId) throw new Error("peeringId is required for 'accept-peering'."); + return `You are about to accept VPC peering connection '${args.peeringId}'. Proceed?`; + } + case "reject-peering": { + if (!args.peeringId) throw new Error("peeringId is required for 'reject-peering'."); + return `You are about to reject VPC peering connection '${args.peeringId}'. This cannot be undone. Proceed?`; + } + } + } + + private requireResourceName(resourceName: string | undefined, context: string): string { + if (!resourceName) { + throw new Error(`resourceName is required for '${context}'.`); + } + return resourceName; + } + + private async startProcessor(args: ToolArgs): Promise { + const name = this.requireResourceName(args.resourceName, "start-processor"); + + const processor = await this.apiClient.getStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, processorName: name } }, + }); + if (processor?.state === "STARTED") { + return { + content: [ + { + type: "text", + text: `Processor '${name}' is already running. Use action 'stop-processor' first if you want to restart it.`, + }, + ], + isError: true, + }; + } + + if (args.tier) { + const tierOrder = ["SP2", "SP5", "SP10", "SP30", "SP50"]; + try { + const ws = await this.apiClient.getStreamWorkspace({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName } }, + }); + const maxTier = ws?.streamConfig?.maxTierSize; + if (maxTier && tierOrder.indexOf(args.tier) > tierOrder.indexOf(maxTier)) { + return { + content: [ + { + type: "text", + text: + `Cannot start processor with tier '${args.tier}' — workspace '${args.workspaceName}' has a maximum tier of '${maxTier}'.\n\n` + + `Options:\n` + + `1. Use a tier ≤ ${maxTier}\n` + + `2. Raise the workspace max tier first with \`atlas-streams-manage\` action='update-workspace' newTier='${args.tier}'`, + }, + ], + isError: true, + }; + } + } catch { + // Soft check — proceed anyway if we can't fetch workspace + } + } + + const hasStartOptions = + args.tier !== undefined || + args.resumeFromCheckpoint !== undefined || + args.startAtOperationTime !== undefined; + + if (hasStartOptions) { + const startBody: Record = {}; + if (args.tier !== undefined) startBody.tier = args.tier; + if (args.resumeFromCheckpoint !== undefined) startBody.resumeFromCheckpoint = args.resumeFromCheckpoint; + if (args.startAtOperationTime !== undefined) startBody.startAtOperationTime = args.startAtOperationTime; + + await this.apiClient.startStreamProcessorWith({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, processorName: name } }, + body: startBody as never, + }); + } else { + await this.apiClient.startStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, processorName: name } }, + }); + } + + const checkpointNote = + args.resumeFromCheckpoint === false + ? " Starting from the beginning (no checkpoint resume)." + : " Resuming from last checkpoint."; + + return { + content: [ + { + type: "text", + text: + `Processor '${name}' started in workspace '${args.workspaceName}'.${checkpointNote}\n\n` + + `Note: Billing for stream processing usage is now active for this processor.\n\n` + + `Use \`atlas-streams-discover\` with action 'diagnose-processor' to monitor health. ` + + `Use \`atlas-streams-manage\` with action 'stop-processor' to stop billing.`, + }, + ], + }; + } + + private async stopProcessor(args: ToolArgs): Promise { + const name = this.requireResourceName(args.resourceName, "stop-processor"); + + const processor = await this.apiClient.getStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, processorName: name } }, + }); + if (processor?.state === "STOPPED" || processor?.state === "CREATED") { + return { + content: [ + { + type: "text", + text: `Processor '${name}' is already stopped (state: ${processor.state}). No action needed.`, + }, + ], + }; + } + + await this.apiClient.stopStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, processorName: name } }, + }); + + return { + content: [ + { + type: "text", + text: + `Processor '${name}' stopped. State preserved for 45 days.\n\n` + + `Use action 'modify-processor' to change its pipeline, or action 'start-processor' to resume.`, + }, + ], + }; + } + + private async modifyProcessor(args: ToolArgs): Promise { + const name = this.requireResourceName(args.resourceName, "modify-processor"); + + const processor = await this.apiClient.getStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, processorName: name } }, + }); + if (processor?.state === "STARTED") { + return { + content: [ + { + type: "text", + text: `Processor '${name}' must be stopped before modifying. Use action 'stop-processor' first.`, + }, + ], + isError: true, + }; + } + + const body: Record = {}; + if (args.pipeline) body.pipeline = args.pipeline; + if (args.newName) body.name = args.newName; + if (args.dlq) body.options = { dlq: args.dlq }; + + if (Object.keys(body).length === 0) { + return { + content: [ + { + type: "text", + text: "No modifications specified. Provide at least one of: pipeline, dlq, or newName.", + }, + ], + isError: true, + }; + } + + await this.apiClient.updateStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, processorName: name } }, + body: body as never, + }); + + const changes = Object.keys(body).join(", "); + return { + content: [ + { + type: "text", + text: + `Processor '${name}' modified (changed: ${changes}).\n\n` + + `Use action 'start-processor' to resume processing with the updated configuration.`, + }, + ], + }; + } + + private async updateWorkspace(args: ToolArgs): Promise { + const body: Record = {}; + if (args.newRegion) { + body.dataProcessRegion = { region: args.newRegion }; + } + if (args.newTier) { + body.streamConfig = { tier: args.newTier }; + } + + if (Object.keys(body).length === 0) { + return { + content: [ + { + type: "text", + text: "No updates specified. Provide at least one of: newRegion or newTier.", + }, + ], + isError: true, + }; + } + + await this.apiClient.updateStreamWorkspace({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName } }, + body: body as never, + }); + + return { + content: [ + { + type: "text", + text: `Workspace '${args.workspaceName}' updated. Use \`atlas-streams-discover\` with action 'inspect-workspace' to verify changes.`, + }, + ], + }; + } + + private async updateConnection(args: ToolArgs): Promise { + const name = this.requireResourceName(args.resourceName, "update-connection"); + + if (!args.connectionConfig) { + throw new Error("connectionConfig is required to update a connection."); + } + + await this.apiClient.updateStreamConnection({ + params: { path: { groupId: args.projectId, tenantName: args.workspaceName, connectionName: name } }, + body: args.connectionConfig as never, + }); + + return { + content: [ + { + type: "text", + text: `Connection '${name}' updated in workspace '${args.workspaceName}'.`, + }, + ], + }; + } + + private async acceptPeering(args: ToolArgs): Promise { + if (!args.peeringId) throw new Error("peeringId is required to accept a VPC peering connection."); + if (!args.requesterAccountId) throw new Error("requesterAccountId is required to accept VPC peering."); + if (!args.requesterVpcId) throw new Error("requesterVpcId is required to accept VPC peering."); + + const peeringId = args.peeringId; + const requesterAccountId = args.requesterAccountId; + const requesterVpcId = args.requesterVpcId; + + await this.apiClient.acceptVpcPeeringConnection({ + params: { path: { groupId: args.projectId, id: peeringId } }, + body: { + requesterAccountId, + requesterVpcId, + }, + }); + + return { + content: [ + { + type: "text", + text: `VPC peering connection '${args.peeringId}' accepted. It may take a few minutes to become active.`, + }, + ], + }; + } + + private async rejectPeering(args: ToolArgs): Promise { + if (!args.peeringId) throw new Error("peeringId is required to reject a VPC peering connection."); + + await this.apiClient.rejectVpcPeeringConnection({ + params: { path: { groupId: args.projectId, id: args.peeringId } }, + }); + + return { + content: [ + { + type: "text", + text: `VPC peering connection '${args.peeringId}' rejected.`, + }, + ], + }; + } +} diff --git a/src/tools/atlas/streams/streamsArgs.ts b/src/tools/atlas/streams/streamsArgs.ts new file mode 100644 index 000000000..cd1f4ae66 --- /dev/null +++ b/src/tools/atlas/streams/streamsArgs.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +const ALLOWED_STREAMS_NAME_REGEX = /^[a-zA-Z0-9_-]+$/; +const ALLOWED_STREAMS_NAME_ERROR = "Name can only contain ASCII letters, numbers, hyphens, and underscores"; + +export const StreamsArgs = { + workspaceName: (): z.ZodString => + z + .string() + .min(1, "Workspace name is required") + .max(64, "Workspace name must be 64 characters or less") + .regex(ALLOWED_STREAMS_NAME_REGEX, ALLOWED_STREAMS_NAME_ERROR), + + processorName: (): z.ZodString => + z + .string() + .min(1, "Processor name is required") + .max(64, "Processor name must be 64 characters or less") + .regex(ALLOWED_STREAMS_NAME_REGEX, ALLOWED_STREAMS_NAME_ERROR), + + connectionName: (): z.ZodString => + z + .string() + .min(1, "Connection name is required") + .max(64, "Connection name must be 64 characters or less") + .regex(ALLOWED_STREAMS_NAME_REGEX, ALLOWED_STREAMS_NAME_ERROR), +}; diff --git a/src/tools/atlas/streams/streamsToolBase.ts b/src/tools/atlas/streams/streamsToolBase.ts new file mode 100644 index 000000000..8828278f2 --- /dev/null +++ b/src/tools/atlas/streams/streamsToolBase.ts @@ -0,0 +1,190 @@ +import { z } from "zod"; +import { AtlasToolBase } from "../atlasTool.js"; +import type { ToolArgs } from "../../tool.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { ApiClientError } from "../../../common/atlas/apiClientError.js"; +import type { StreamsToolMetadata } from "../../../telemetry/types.js"; + +export abstract class StreamsToolBase extends AtlasToolBase { + protected verifyAllowed(): boolean { + if (!this.isFeatureEnabled("streams")) { + return false; + } + return super.verifyAllowed(); + } + + protected override handleError( + error: unknown, + args: ToolArgs + ): Promise | CallToolResult { + if (error instanceof ApiClientError) { + const statusCode = error.response.status; + + if (statusCode === 404) { + return { + content: [ + { + type: "text", + text: `Resource not found: ${error.message}\n\nUse \`atlas-streams-discover\` to list available workspaces, connections, and processors.`, + }, + ], + isError: true, + }; + } + + if (statusCode === 403 && error.message.includes("active") && error.message.includes("processor")) { + return { + content: [ + { + type: "text", + text: `Received a Forbidden API Error: ${error.message}\n\nThis may be because the workspace has active processors. Stop all processors first with \`atlas-streams-manage\` action 'stop-processor', then retry deletion.`, + }, + ], + isError: true, + }; + } + + if (statusCode === 400) { + const msg = error.message; + let hint = + "This usually indicates invalid configuration or pipeline syntax. Check the request parameters and try again."; + + if (msg.includes("IDLUnknownField") && msg.includes("topic") && msg.includes("AtlasCollection")) { + hint = + "The 'topic' field is not valid inside $merge. Use $emit (not $merge) to write to Kafka: {$emit: {connectionName, topic}}."; + } else if (msg.includes("IDLUnknownField") && msg.includes("schemaRegistryName")) { + hint = + "Use schemaRegistry: {connectionName, valueSchema: {type: 'avro', schema: {}}} instead of schemaRegistryName."; + } else if (msg.includes("IDLFailedToParse") && msg.includes("valueSchema") && msg.includes("missing")) { + hint = + "schemaRegistry.valueSchema is required. Include: {type: 'avro', schema: {}, options: {autoRegisterSchemas: true}}."; + } else if (msg.includes("BadValue") && msg.includes("Enumeration") && msg.includes("type")) { + hint = "Schema type values are case-sensitive. Use lowercase 'avro' (not 'AVRO' or 'Avro')."; + } else if (msg.includes("IDLUnknownField") && msg.includes("MergeOperatorSpec")) { + hint = + "Invalid field in $merge stage. $merge writes to Atlas clusters: {$merge: {into: {connectionName, db, coll}}}. For Kafka/external sinks, use $emit instead."; + } + + return { + content: [ + { + type: "text", + text: `Bad Request: ${msg}\n\n${hint}`, + }, + ], + isError: true, + }; + } + + if (statusCode === 409) { + return { + content: [ + { + type: "text", + text: `Conflict: ${error.message}\n\nThe resource may already exist or be in a state that prevents this operation. Use \`atlas-streams-discover\` to check current state.`, + }, + ], + isError: true, + }; + } + } + + if (error instanceof Error && error.message.includes("resumeFromCheckpoint")) { + return { + content: [ + { + type: "text", + text: + `Checkpoint conflict: ${error.message}\n\n` + + `This typically occurs when a window stage interval was changed. Options:\n` + + `1. Restart with resumeFromCheckpoint=false (drops accumulated window state)\n` + + `2. Delete and recreate the processor if option 1 doesn't work`, + }, + ], + isError: true, + }; + } + + if ( + error instanceof Error && + (error.message.includes("SASL") || error.message.includes("authentication failed")) + ) { + return { + content: [ + { + type: "text", + text: + `Authentication failure: ${error.message}\n\n` + + `Check your Kafka connection credentials. Common issues:\n` + + `- Password may have a prefix (e.g. 'cflt/') that must be included\n` + + `- Mechanism mismatch (PLAIN vs SCRAM-256 vs SCRAM-512)\n` + + `Use \`atlas-streams-discover\` with action 'diagnose-processor' to see detailed error logs.`, + }, + ], + isError: true, + }; + } + + if (error instanceof Error && error.message.includes("INVALID_STATE")) { + return { + content: [ + { + type: "text", + text: `Invalid state transition: ${error.message}\n\nUse \`atlas-streams-discover\` with action 'inspect-processor' to check the current processor state before retrying.`, + }, + ], + isError: true, + }; + } + + return super.handleError(error, args); + } + + protected static extractConnectionNames(obj: unknown): Set { + const names = new Set(); + if (Array.isArray(obj)) { + for (const item of obj) { + for (const name of StreamsToolBase.extractConnectionNames(item)) { + names.add(name); + } + } + } else if (obj !== null && typeof obj === "object") { + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { + if (key === "connectionName" && typeof value === "string") { + names.add(value); + } else { + for (const name of StreamsToolBase.extractConnectionNames(value)) { + names.add(name); + } + } + } + } + return names; + } + + protected override resolveTelemetryMetadata( + args: ToolArgs, + { result }: { result: CallToolResult } + ): StreamsToolMetadata { + const baseMetadata = super.resolveTelemetryMetadata(args, { result }); + const metadata: StreamsToolMetadata = { ...baseMetadata }; + + const argsShape = z.object(this.argsShape); + const parsedResult = argsShape.safeParse(args); + if (!parsedResult.success) { + return metadata; + } + + const data = parsedResult.data; + + if ("action" in data && typeof data.action === "string") { + metadata.action = data.action; + } + if ("resource" in data && typeof data.resource === "string") { + metadata.resource = data.resource; + } + + return metadata; + } +} diff --git a/src/tools/atlas/streams/teardown.ts b/src/tools/atlas/streams/teardown.ts new file mode 100644 index 000000000..f46b75b16 --- /dev/null +++ b/src/tools/atlas/streams/teardown.ts @@ -0,0 +1,261 @@ +import { z } from "zod"; +import { StreamsToolBase } from "./streamsToolBase.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs } from "../../tool.js"; +import { AtlasArgs } from "../../args.js"; +import { StreamsArgs } from "./streamsArgs.js"; + +const TeardownResource = z.enum(["processor", "connection", "workspace", "privatelink", "peering"]); + +export class StreamsTeardownTool extends StreamsToolBase { + static toolName = "atlas-streams-teardown"; + static operationType: OperationType = "delete"; + + public description = + "Delete Atlas Stream Processing resources. " + + "Also use for 'remove my workspace', 'delete all processors', or 'clean up my streams environment'. " + + "Performs basic safety checks before deletion: summarizes counts of processors and connections, " + + "highlights connections referenced by processors where possible, and surfaces API errors if processors are still running when deletion is attempted. " + + "Use `atlas-streams-discover` to review resources before deleting."; + + public argsShape = { + projectId: AtlasArgs.projectId().describe( + "Atlas project ID. Use atlas-list-projects to find project IDs if not available." + ), + resource: TeardownResource.describe( + "What to delete. 'processor': stop first recommended. 'connection': ensure no processor references it. " + + "'workspace': removes all contained connections and processors." + ), + workspaceName: StreamsArgs.workspaceName() + .optional() + .describe("Workspace name. Required for workspace, connection, and processor deletion."), + resourceName: z.string().optional().describe("Name or ID of the specific resource to delete."), + }; + + protected override getConfirmationMessage(args: ToolArgs): string { + switch (args.resource) { + case "workspace": { + const workspace = this.requireWorkspaceName(args); + return ( + `You are about to delete workspace '${workspace}'. ` + + `This will permanently remove ALL connections and processors in this workspace. ` + + `This action cannot be undone. Proceed?` + ); + } + case "processor": { + const workspace = this.requireWorkspaceName(args); + const name = this.requireResourceName(args); + return ( + `You are about to delete processor '${name}' from workspace '${workspace}'. ` + + `If the processor is running, it will be stopped first. ` + + `All processor state and checkpoints will be permanently lost. Proceed?` + ); + } + case "connection": { + const workspace = this.requireWorkspaceName(args); + const name = this.requireResourceName(args); + return ( + `You are about to delete connection '${name}' from workspace '${workspace}'. ` + + `Any processors referencing this connection will fail. Proceed?` + ); + } + case "privatelink": { + const name = this.requireResourceName(args); + return `You are about to delete PrivateLink connection '${name}'. This cannot be undone. Proceed?`; + } + case "peering": { + const name = this.requireResourceName(args); + return `You are about to delete VPC peering connection '${name}'. This cannot be undone. Proceed?`; + } + } + } + + protected async execute(args: ToolArgs): Promise { + switch (args.resource) { + case "processor": + return this.deleteProcessor(args); + case "connection": + return this.deleteConnection(args); + case "workspace": + return this.deleteWorkspace(args); + case "privatelink": + return this.deletePrivateLink(args); + case "peering": + return this.deletePeering(args); + default: + return { + content: [{ type: "text", text: `Unknown resource type: ${args.resource as string}` }], + isError: true, + }; + } + } + + private requireWorkspaceName(args: ToolArgs): string { + if (!args.workspaceName) { + throw new Error("workspaceName is required for this deletion."); + } + return args.workspaceName; + } + + private requireResourceName(args: ToolArgs): string { + if (!args.resourceName) { + throw new Error("resourceName is required for this deletion."); + } + return args.resourceName; + } + + private async deleteProcessor(args: ToolArgs): Promise { + const workspace = this.requireWorkspaceName(args); + const name = this.requireResourceName(args); + + const processor = await this.apiClient.getStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: workspace, processorName: name } }, + }); + if (processor?.state === "STARTED") { + await this.apiClient.stopStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: workspace, processorName: name } }, + }); + } + + await this.apiClient.deleteStreamProcessor({ + params: { path: { groupId: args.projectId, tenantName: workspace, processorName: name } }, + }); + + return { + content: [ + { + type: "text", + text: `Processor '${name}' deleted from workspace '${workspace}'. All state and checkpoints have been permanently removed.`, + }, + ], + }; + } + + private async deleteConnection(args: ToolArgs): Promise { + const workspace = this.requireWorkspaceName(args); + const name = this.requireResourceName(args); + + // Safety: check if any processor references this connection + try { + const processors = await this.apiClient.getStreamProcessors({ + params: { path: { groupId: args.projectId, tenantName: workspace } }, + }); + const referencingProcessors = (processors?.results ?? []).filter((p) => { + const referencedNames = StreamsToolBase.extractConnectionNames(p.pipeline ?? []); + return referencedNames.has(name); + }); + + if (referencingProcessors.length > 0) { + const runningOnes = referencingProcessors.filter((p) => p.state === "STARTED"); + if (runningOnes.length > 0) { + const names = runningOnes.map((p) => p.name).join(", "); + return { + content: [ + { + type: "text", + text: + `Warning: Connection '${name}' is referenced by running processor(s): ${names}. ` + + `Stop these processors first with \`atlas-streams-manage\` action 'stop-processor', then retry deletion.`, + }, + ], + isError: true, + }; + } + } + } catch { + // If we can't check processors, proceed with deletion anyway + } + + await this.apiClient.deleteStreamConnection({ + params: { path: { groupId: args.projectId, tenantName: workspace, connectionName: name } }, + }); + + return { + content: [ + { + type: "text", + text: + `Connection '${name}' deletion initiated in workspace '${workspace}'. ` + + `Use \`atlas-streams-discover\` with action 'list-connections' to confirm when deletion is complete.`, + }, + ], + }; + } + + private async deleteWorkspace(args: ToolArgs): Promise { + const workspace = this.requireWorkspaceName(args); + + // Safety: summarize what will be deleted + let impactNote = ""; + try { + const [connectionsResult, processorsResult] = await Promise.allSettled([ + this.apiClient.listStreamConnections({ + params: { path: { groupId: args.projectId, tenantName: workspace } }, + }), + this.apiClient.getStreamProcessors({ + params: { path: { groupId: args.projectId, tenantName: workspace } }, + }), + ]); + + const connectionCount = + connectionsResult.status === "fulfilled" ? (connectionsResult.value?.results?.length ?? 0) : 0; + const processorCount = + processorsResult.status === "fulfilled" ? (processorsResult.value?.results?.length ?? 0) : 0; + + if (connectionCount > 0 || processorCount > 0) { + impactNote = ` This will also remove ${processorCount} processor(s) and ${connectionCount} connection(s).`; + } + } catch { + // If we can't get counts, proceed anyway + } + + await this.apiClient.deleteStreamWorkspace({ + params: { path: { groupId: args.projectId, tenantName: workspace } }, + }); + + return { + content: [ + { + type: "text", + text: + `Workspace '${workspace}' deletion initiated.${impactNote} ` + + `Use \`atlas-streams-discover\` with action 'list-workspaces' to confirm when deletion is complete.`, + }, + ], + }; + } + + private async deletePrivateLink(args: ToolArgs): Promise { + const id = this.requireResourceName(args); + await this.apiClient.deletePrivateLinkConnection({ + params: { path: { groupId: args.projectId, connectionId: id } }, + }); + + return { + content: [ + { + type: "text", + text: + `PrivateLink connection '${id}' deletion initiated. ` + + `Use \`atlas-streams-discover\` with action 'get-networking' to confirm when deletion is complete.`, + }, + ], + }; + } + + private async deletePeering(args: ToolArgs): Promise { + const id = this.requireResourceName(args); + await this.apiClient.deleteVpcPeeringConnection({ + params: { path: { groupId: args.projectId, id: id } }, + }); + + return { + content: [ + { + type: "text", + text: `VPC peering connection '${id}' deletion initiated.`, + }, + ], + }; + } +} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index 0b46621f9..bbb6de36c 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -11,3 +11,7 @@ export { ListOrganizationsTool } from "./read/listOrgs.js"; export { ConnectClusterTool } from "./connect/connectCluster.js"; export { ListAlertsTool } from "./read/listAlerts.js"; export { GetPerformanceAdvisorTool } from "./read/getPerformanceAdvisor.js"; +export { StreamsDiscoverTool } from "./streams/discover.js"; +export { StreamsBuildTool } from "./streams/build.js"; +export { StreamsManageTool } from "./streams/manage.js"; +export { StreamsTeardownTool } from "./streams/teardown.js"; diff --git a/tests/browser/polyfills/zlib/index.ts b/tests/browser/polyfills/zlib/index.ts index b76db1ab5..a11babe39 100644 --- a/tests/browser/polyfills/zlib/index.ts +++ b/tests/browser/polyfills/zlib/index.ts @@ -4,4 +4,7 @@ export function inflate() { export function deflate() { // noop } -export default { inflate, deflate }; +export function gunzip(buf: Buffer, callback: (err: Error | null, result: Buffer) => void) { + callback(null, buf); +} +export default { inflate, deflate, gunzip }; diff --git a/tests/browser/vitest.config.ts b/tests/browser/vitest.config.ts index afe887fcf..9cf81917f 100644 --- a/tests/browser/vitest.config.ts +++ b/tests/browser/vitest.config.ts @@ -75,6 +75,7 @@ export default defineConfig({ // Built-in Node.js modules imported by the driver directly and used in // ways that requires us to provide a no-op polyfill zlib: localPolyfill("zlib"), + "node:zlib": localPolyfill("zlib"), }, }, }); diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts index feab29332..19af96e11 100644 --- a/tests/integration/elicitation.test.ts +++ b/tests/integration/elicitation.test.ts @@ -35,11 +35,14 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to drop the `test-db` database"), - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringContaining("You are about to drop the `test-db` database"), + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + mode: "form", + }, + { timeout: 300000 } + ); // Should attempt to execute (will fail due to no connection, but confirms flow worked) expect(result.isError).toBe(true); @@ -80,11 +83,14 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to drop the `test-collection` collection"), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringContaining("You are about to drop the `test-collection` collection"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", + }, + { timeout: 300000 } + ); }); it("should request confirmation for delete-many tool", async () => { @@ -100,11 +106,14 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to delete documents"), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringContaining("You are about to delete documents"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", + }, + { timeout: 300000 } + ); }); it("should request confirmation for create-db-user tool", async () => { @@ -120,11 +129,14 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining("You are about to create a database user"), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringContaining("You are about to create a database user"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", + }, + { timeout: 300000 } + ); }); it("should request confirmation for create-access-list tool", async () => { @@ -139,13 +151,16 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringContaining( - "You are about to add the following entries to the access list" - ), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringContaining( + "You are about to add the following entries to the access list" + ), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", + }, + { timeout: 300000 } + ); }); }); @@ -222,13 +237,16 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching( - /You are about to execute the `list-databases` tool which requires additional confirmation. Would you like to proceed\?/ - ), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringMatching( + /You are about to execute the `list-databases` tool which requires additional confirmation. Would you like to proceed\?/ + ), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", + }, + { timeout: 300000 } + ); }); it("should not request confirmation when tool is removed from default confirmationRequiredTools", async () => { @@ -268,11 +286,14 @@ describe("Elicitation Integration Tests", () => { }, }); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching(/project.*507f1f77bcf86cd799439011/), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringMatching(/project.*507f1f77bcf86cd799439011/), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", + }, + { timeout: 300000 } + ); }); it("should include filter details in delete-many confirmation", async () => { @@ -287,11 +308,14 @@ describe("Elicitation Integration Tests", () => { }, }); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching(/mydb.*database/), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: expect.stringMatching(/mydb.*database/), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", + }, + { timeout: 300000 } + ); }); }, { diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index 45988e73a..fc00f16c4 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -25,6 +25,23 @@ export function describeWithAtlas(name: string, fn: IntegrationTestFunction): vo }); } +export function describeWithStreams(name: string, fn: IntegrationTestFunction): void { + const describeFn = + !process.env.MDB_MCP_API_CLIENT_ID?.length || !process.env.MDB_MCP_API_CLIENT_SECRET?.length + ? describe.skip + : describe; + describeFn(name, () => { + const integration = setupIntegrationTest(() => ({ + ...defaultTestConfig, + apiClientId: process.env.MDB_MCP_API_CLIENT_ID || "test-client", + apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET || "test-secret", + apiBaseUrl: process.env.MDB_MCP_API_BASE_URL ?? "https://cloud-dev.mongodb.com", + previewFeatures: ["streams"], + })); + fn(integration); + }); +} + interface ProjectTestArgs { getProjectId: () => string; getIpAddress: () => string; @@ -36,10 +53,18 @@ interface ClusterTestArgs { getClusterName: () => string; } +interface WorkspaceTestArgs { + getProjectId: () => string; + getWorkspaceName: () => string; + getClusterConnectionName: () => string; +} + type ProjectTestFunction = (args: ProjectTestArgs) => void; type ClusterTestFunction = (args: ClusterTestArgs) => void; +type WorkspaceTestFunction = (args: WorkspaceTestArgs) => void; + export function withCredentials(integration: IntegrationTest, fn: IntegrationTestFunction): SuiteCollector { const describeFn = !process.env.MDB_MCP_API_CLIENT_ID?.length || !process.env.MDB_MCP_API_CLIENT_SECRET?.length @@ -326,3 +351,163 @@ export function withCluster(integration: IntegrationTest, fn: ClusterTestFunctio }); }); } + +export function withWorkspace(integration: IntegrationTest, fn: WorkspaceTestFunction): SuiteCollector { + return withProject(integration, ({ getProjectId }) => { + describe("with workspace", () => { + const workspaceName: string = `testws${randomId().slice(0, 12)}`; + const clusterName: string = `testcluster${randomId().slice(0, 8)}`; + const clusterConnectionName: string = `clusterconn${randomId().slice(0, 8)}`; + + beforeAll(async () => { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + assertApiClientIsAvailable(session); + const apiClient = session.apiClient; + + // Create workspace and free-tier cluster in parallel + await Promise.all([ + apiClient.createStreamWorkspace({ + params: { path: { groupId: projectId } }, + body: { + name: workspaceName, + dataProcessRegion: { + cloudProvider: "AWS", + region: "VIRGINIA_USA", + }, + streamConfig: { + tier: "SP10", + }, + } as never, + }), + apiClient.createCluster({ + params: { path: { groupId: projectId } }, + body: { + name: clusterName, + clusterType: "REPLICASET", + replicationSpecs: [ + { + zoneName: "Zone 1", + regionConfigs: [ + { + providerName: "TENANT", + backingProviderName: "AWS", + regionName: "US_EAST_1", + electableSpecs: { + instanceSize: "M0", + }, + }, + ], + }, + ], + terminationProtectionEnabled: false, + } as unknown as ClusterDescription20240805, + }), + ]); + + // Wait for workspace readiness (up to 120s) + let workspaceReady = false; + for (let i = 0; i < 120; i++) { + try { + const ws = await apiClient.getStreamWorkspace({ + params: { + path: { groupId: projectId, tenantName: workspaceName }, + }, + }); + if (ws?.name === workspaceName) { + workspaceReady = true; + break; + } + } catch { + // Workspace not ready yet + } + await sleep(1000); + } + if (!workspaceReady) { + throw new Error( + `Workspace readiness timeout: '${workspaceName}' did not become readable within 120 seconds` + ); + } + + // Create a Sample connection for tests + await apiClient.createStreamConnection({ + params: { path: { groupId: projectId, tenantName: workspaceName } }, + body: { + name: "sample_stream_solar", + type: "Sample", + } as never, + }); + + // Wait for the cluster to become IDLE before creating the Cluster connection + await waitCluster(session, projectId, clusterName, (cluster) => { + return cluster.stateName === "IDLE"; + }); + + // Create a Cluster connection in the workspace for processor tests + await apiClient.createStreamConnection({ + params: { path: { groupId: projectId, tenantName: workspaceName } }, + body: { + name: clusterConnectionName, + type: "Cluster", + clusterName: clusterName, + dbRoleToExecute: { + role: "readWriteAnyDatabase", + type: "BUILT_IN", + }, + } as never, + }); + }, 600_000); + + afterAll(async () => { + if (!getProjectId()) { + return; + } + const session = integration.mcpServer().session; + assertApiClientIsAvailable(session); + const apiClient = session.apiClient; + try { + await apiClient.deleteStreamWorkspace({ + params: { + path: { + groupId: getProjectId(), + tenantName: workspaceName, + }, + }, + }); + // Wait for workspace deletion to complete before deleting the cluster, + // because the cluster cannot be deleted while a workspace connection references it. + for (let i = 0; i < 120; i++) { + try { + await apiClient.getStreamWorkspace({ + params: { + path: { + groupId: getProjectId(), + tenantName: workspaceName, + }, + }, + }); + await sleep(1000); + } catch { + break; + } + } + } catch (error) { + console.log("Failed to delete workspace:", error); + } + try { + await deleteCluster(session, getProjectId(), clusterName); + } catch (error) { + console.log("Failed to delete cluster:", error); + } + }); + + const args = { + getProjectId: (): string => getProjectId(), + getWorkspaceName: (): string => workspaceName, + getClusterConnectionName: (): string => clusterConnectionName, + }; + + fn(args); + }); + }); +} diff --git a/tests/integration/tools/atlas/streams/build.test.ts b/tests/integration/tools/atlas/streams/build.test.ts new file mode 100644 index 000000000..ea8c046c9 --- /dev/null +++ b/tests/integration/tools/atlas/streams/build.test.ts @@ -0,0 +1,163 @@ +import { getResponseContent } from "../../../helpers.js"; +import { describeWithStreams, withWorkspace, randomId, assertApiClientIsAvailable } from "../atlasHelpers.js"; +import { afterAll, describe, expect, it } from "vitest"; + +describeWithStreams("atlas-streams-build", (integration) => { + describe("tool registration", () => { + it("registers atlas-streams-build with correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const tool = tools.find((t) => t.name === "atlas-streams-build"); + expect(tool).toBeDefined(); + expect(tool!.inputSchema.type).toBe("object"); + expect(tool!.inputSchema.properties).toBeDefined(); + expect(tool!.inputSchema.properties).toHaveProperty("projectId"); + expect(tool!.inputSchema.properties).toHaveProperty("resource"); + }); + }); + + withWorkspace(integration, ({ getProjectId, getWorkspaceName }) => { + describe("HTTPS connection", () => { + const connectionName = `httpsconn${randomId().slice(0, 8)}`; + + afterAll(async () => { + const session = integration.mcpServer().session; + assertApiClientIsAvailable(session); + try { + await session.apiClient.deleteStreamConnection({ + params: { + path: { + groupId: getProjectId(), + tenantName: getWorkspaceName(), + connectionName, + }, + }, + }); + } catch { + // ignore cleanup errors + } + }); + + it("creates an HTTPS connection", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + connectionName, + connectionType: "Https", + connectionConfig: { + url: "https://httpbin.org/post", + }, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain(connectionName); + expect(content).toContain("Https"); + }); + }); + + describe("kafka connection", () => { + const kafkaConnName = `kafkaconn${randomId().slice(0, 8)}`; + + afterAll(async () => { + const session = integration.mcpServer().session; + assertApiClientIsAvailable(session); + try { + await session.apiClient.deleteStreamConnection({ + params: { + path: { + groupId: getProjectId(), + tenantName: getWorkspaceName(), + connectionName: kafkaConnName, + }, + }, + }); + } catch { + // ignore cleanup errors + } + }); + + it("creates a Kafka connection with dummy creds", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + connectionName: kafkaConnName, + connectionType: "Kafka", + connectionConfig: { + bootstrapServers: "dummy-broker.example.com:9092", + authentication: { + mechanism: "PLAIN", + username: "dummy-user", + password: "dummy-pass", + }, + security: { protocol: "SASL_SSL" }, + }, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain(kafkaConnName); + expect(content).toContain("Kafka"); + }); + }); + + describe("schema registry connection", () => { + const srConnName = `srconn${randomId().slice(0, 8)}`; + + afterAll(async () => { + const session = integration.mcpServer().session; + assertApiClientIsAvailable(session); + try { + await session.apiClient.deleteStreamConnection({ + params: { + path: { + groupId: getProjectId(), + tenantName: getWorkspaceName(), + connectionName: srConnName, + }, + }, + }); + } catch { + // ignore cleanup errors + } + }); + + it("creates a SchemaRegistry connection with dummy creds", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + connectionName: srConnName, + connectionType: "SchemaRegistry", + connectionConfig: { + schemaRegistryUrls: ["https://dummy-registry.example.com"], + provider: "CONFLUENT", + schemaRegistryAuthentication: { + type: "USER_INFO", + username: "dummy-user", + password: "dummy-pass", + }, + }, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain(srConnName); + expect(content).toContain("SchemaRegistry"); + }); + }); + + // TODO: Add integration tests requiring external infrastructure: + // - S3 connection creation (requires AWS IAM role ARN registered in Atlas) + // - AWSKinesisDataStreams connection creation (requires AWS IAM role ARN) + // - AWSLambda connection creation (requires AWS IAM role ARN) + // - PrivateLink creation (requires provider-specific infrastructure) + }); +}); diff --git a/tests/integration/tools/atlas/streams/discover.test.ts b/tests/integration/tools/atlas/streams/discover.test.ts new file mode 100644 index 000000000..cafe24c28 --- /dev/null +++ b/tests/integration/tools/atlas/streams/discover.test.ts @@ -0,0 +1,212 @@ +import { expectDefined, getResponseContent } from "../../../helpers.js"; +import { describeWithStreams, withWorkspace } from "../atlasHelpers.js"; +import { describe, expect, it } from "vitest"; + +describeWithStreams("atlas-streams-discover", (integration) => { + describe("tool registration", () => { + it("registers atlas-streams-discover with correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const tool = tools.find((t) => t.name === "atlas-streams-discover"); + expectDefined(tool); + expect(tool.inputSchema.type).toBe("object"); + expectDefined(tool.inputSchema.properties); + expect(tool.inputSchema.properties).toHaveProperty("projectId"); + expect(tool.inputSchema.properties).toHaveProperty("action"); + }); + }); + + withWorkspace(integration, ({ getProjectId, getWorkspaceName }) => { + it("list-workspaces — returns workspace list", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "list-workspaces", + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("workspace(s)"); + expect(content).toContain(getWorkspaceName()); + }); + + it("inspect-workspace — returns workspace details", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-workspace", + workspaceName: getWorkspaceName(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(getWorkspaceName()); + expect(content).toContain(" { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "list-connections", + workspaceName: getWorkspaceName(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("connection(s)"); + expect(content).toContain("sample_stream_solar"); + }); + + it("inspect-connection — returns connection details", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-connection", + workspaceName: getWorkspaceName(), + resourceName: "sample_stream_solar", + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(" { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "list-workspaces", + responseFormat: "detailed", + }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBeFalsy(); + expect(content).toContain("dataProcessRegion"); + }); + + it("list-connections — detailed format includes full object", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "list-connections", + workspaceName: getWorkspaceName(), + responseFormat: "detailed", + }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBeFalsy(); + // Detailed format includes full connection objects, not just name/type/state + expect(content).toContain("sample_stream_solar"); + expect(content).toContain("connection(s)"); + }); + + it("list-processors — initially empty", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "list-processors", + workspaceName: getWorkspaceName(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("No processors found"); + }); + + it("get-networking — returns networking section", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "get-networking", + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("PrivateLink"); + }); + + it("get-logs — operational logs", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "get-logs", + workspaceName: getWorkspaceName(), + logType: "operational", + }, + }); + expect(response.isError).toBeFalsy(); + const content = getResponseContent(response.content); + // Any of these are valid code paths: logs returned, empty, or decompression issue + expect( + content.includes("logs for workspace") || + content.includes("No logs available") || + content.includes("Could not decompress") + ).toBe(true); + }); + + it("get-logs — audit logs", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "get-logs", + workspaceName: getWorkspaceName(), + logType: "audit", + }, + }); + expect(response.isError).toBeFalsy(); + const content = getResponseContent(response.content); + expect( + content.includes("logs for workspace") || + content.includes("No logs available") || + content.includes("Could not decompress") + ).toBe(true); + }); + + it("inspect-workspace — error without workspaceName", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-workspace", + }, + }); + expect(response.isError).toBeTruthy(); + const content = getResponseContent(response.content); + expect(content).toContain("workspaceName is required"); + }); + + it("inspect-processor — error without resourceName", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-processor", + workspaceName: getWorkspaceName(), + }, + }); + expect(response.isError).toBeTruthy(); + const content = getResponseContent(response.content); + expect(content).toContain("resourceName is required"); + }); + + it("inspect-connection — 404 for nonexistent connection", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-connection", + workspaceName: getWorkspaceName(), + resourceName: "nonexistent_conn", + }, + }); + expect(response.isError).toBeTruthy(); + const content = getResponseContent(response.content); + expect(content).toContain("not found"); + }); + }); +}); diff --git a/tests/integration/tools/atlas/streams/manage.test.ts b/tests/integration/tools/atlas/streams/manage.test.ts new file mode 100644 index 000000000..84e04344c --- /dev/null +++ b/tests/integration/tools/atlas/streams/manage.test.ts @@ -0,0 +1,126 @@ +import { getResponseContent } from "../../../helpers.js"; +import { describeWithStreams, withWorkspace, randomId } from "../atlasHelpers.js"; +import { beforeAll, describe, expect, it } from "vitest"; + +describeWithStreams("atlas-streams-manage", (integration) => { + describe("tool registration", () => { + it("registers atlas-streams-manage with correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const tool = tools.find((t) => t.name === "atlas-streams-manage"); + expect(tool).toBeDefined(); + expect(tool!.inputSchema.type).toBe("object"); + expect(tool!.inputSchema.properties).toBeDefined(); + expect(tool!.inputSchema.properties).toHaveProperty("projectId"); + expect(tool!.inputSchema.properties).toHaveProperty("action"); + }); + }); + + withWorkspace(integration, ({ getProjectId, getWorkspaceName, getClusterConnectionName }) => { + describe("processor management", () => { + const processorName = `manageproc${randomId().slice(0, 8)}`; + + beforeAll(async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "processor", + workspaceName: getWorkspaceName(), + processorName, + pipeline: [ + { $source: { connectionName: "sample_stream_solar" } }, + { + $merge: { + into: { + connectionName: getClusterConnectionName(), + db: "test", + coll: "out", + }, + }, + }, + ], + autoStart: false, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(processorName); + expect(content).toContain("deployed"); + }, 60_000); + + it("start-processor — starts successfully", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "start-processor", + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("started"); + }, 30_000); + + it("stop-processor — stops successfully", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "stop-processor", + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("stopped"); + }, 30_000); + + it("modify-processor — changes pipeline", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "modify-processor", + resourceName: processorName, + pipeline: [ + { $source: { connectionName: "sample_stream_solar" } }, + { $match: { device_id: "device_1" } }, + { + $merge: { + into: { + connectionName: getClusterConnectionName(), + db: "test", + coll: "out", + }, + }, + }, + ], + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("modified"); + }, 30_000); + + it("update-workspace — changes tier to SP30", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "update-workspace", + newTier: "SP30", + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("updated"); + }, 30_000); + }); + + // TODO(CLOUDP-388366): Add integration tests requiring VPC peering infrastructure: + // - accept-peering + // - reject-peering + }); +}); diff --git a/tests/integration/tools/atlas/streams/teardown.test.ts b/tests/integration/tools/atlas/streams/teardown.test.ts new file mode 100644 index 000000000..0b926f3d1 --- /dev/null +++ b/tests/integration/tools/atlas/streams/teardown.test.ts @@ -0,0 +1,60 @@ +import { getResponseContent } from "../../../helpers.js"; +import { describeWithStreams, withWorkspace, randomId } from "../atlasHelpers.js"; +import { beforeAll, describe, expect, it } from "vitest"; + +describeWithStreams("atlas-streams-teardown", (integration) => { + describe("tool registration", () => { + it("registers atlas-streams-teardown with correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const tool = tools.find((t) => t.name === "atlas-streams-teardown"); + expect(tool).toBeDefined(); + expect(tool!.inputSchema.type).toBe("object"); + expect(tool!.inputSchema.properties).toBeDefined(); + expect(tool!.inputSchema.properties).toHaveProperty("projectId"); + expect(tool!.inputSchema.properties).toHaveProperty("resource"); + }); + }); + + withWorkspace(integration, ({ getProjectId, getWorkspaceName }) => { + describe("connection deletion", () => { + const teardownConnName = `teardownconn${randomId().slice(0, 8)}`; + + beforeAll(async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + connectionName: teardownConnName, + connectionType: "Https", + connectionConfig: { + url: "https://httpbin.org/post", + }, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Failed to create teardown connection: ${content}`).toBeFalsy(); + }, 30_000); + + it("deletes connection via teardown tool", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-teardown", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + resourceName: teardownConnName, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("deletion initiated"); + }, 30_000); + }); + + // TODO: Add integration tests requiring external infrastructure: + // - PrivateLink deletion (requires PrivateLink infrastructure) + // - Peering deletion (requires VPC peering setup) + }); +}); diff --git a/tests/integration/tools/atlas/streams/workflow.test.ts b/tests/integration/tools/atlas/streams/workflow.test.ts new file mode 100644 index 000000000..66b1363b3 --- /dev/null +++ b/tests/integration/tools/atlas/streams/workflow.test.ts @@ -0,0 +1,403 @@ +import { expectDefined, getResponseContent } from "../../../helpers.js"; +import { describeWithStreams, withWorkspace, randomId, assertApiClientIsAvailable } from "../atlasHelpers.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +describeWithStreams("atlas-streams workflows", (integration) => { + withWorkspace(integration, ({ getProjectId, getWorkspaceName, getClusterConnectionName }) => { + describe("connection update + verify", () => { + const connectionName = `httpsconn${randomId().slice(0, 8)}`; + + beforeAll(async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + connectionName, + connectionType: "Https", + connectionConfig: { + url: "https://httpbin.org/post", + }, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Failed to create connection: ${content}`).toBeFalsy(); + }, 30_000); + + afterAll(async () => { + const session = integration.mcpServer().session; + assertApiClientIsAvailable(session); + try { + await session.apiClient.deleteStreamConnection({ + params: { + path: { + groupId: getProjectId(), + tenantName: getWorkspaceName(), + connectionName, + }, + }, + }); + } catch { + // ignore cleanup errors + } + }); + + it("update-connection — changes URL", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "update-connection", + resourceName: connectionName, + connectionConfig: { + name: connectionName, + type: "Https", + url: "https://httpbin.org/get", + }, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("updated"); + }); + + it("update-connection — verify via inspect", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-connection", + workspaceName: getWorkspaceName(), + resourceName: connectionName, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBeFalsy(); + expect(content).toContain("httpbin.org/get"); + }); + }); + + describe("connection teardown", () => { + const teardownConnName = `teardownconn${randomId().slice(0, 8)}`; + + beforeAll(async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + connectionName: teardownConnName, + connectionType: "Https", + connectionConfig: { + url: "https://httpbin.org/post", + }, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Failed to create teardown connection: ${content}`).toBeFalsy(); + }, 30_000); + + it("creates connection for teardown test", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-connection", + workspaceName: getWorkspaceName(), + resourceName: teardownConnName, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBeFalsy(); + expect(content).toContain(teardownConnName); + }); + + it("deletes connection via teardown tool", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-teardown", + arguments: { + projectId: getProjectId(), + resource: "connection", + workspaceName: getWorkspaceName(), + resourceName: teardownConnName, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("deletion initiated"); + }, 30_000); + }); + + describe("processor lifecycle", () => { + const processorName = `testproc${randomId().slice(0, 8)}`; + + beforeAll(async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "processor", + workspaceName: getWorkspaceName(), + processorName, + pipeline: [ + { $source: { connectionName: "sample_stream_solar" } }, + { + $merge: { + into: { + connectionName: getClusterConnectionName(), + db: "test", + coll: "out", + }, + }, + }, + ], + autoStart: false, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(processorName); + expect(content).toContain("deployed"); + }, 60_000); + + it("creates processor successfully", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-processor", + workspaceName: getWorkspaceName(), + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(processorName); + }); + + describe("atlas-streams-discover — after processor exists", () => { + it("inspect-processor — returns details", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-processor", + workspaceName: getWorkspaceName(), + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain(processorName); + expect(content).toContain(" { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "diagnose-processor", + workspaceName: getWorkspaceName(), + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("Processor State"); + expect(content).toContain(processorName); + }); + }); + + describe("atlas-streams-manage", () => { + it("start-processor — starts successfully", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "start-processor", + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("started"); + }, 30_000); + + it("stop-processor — stops successfully", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "stop-processor", + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("stopped"); + }, 30_000); + + it("modify-processor — changes pipeline", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "modify-processor", + resourceName: processorName, + pipeline: [ + { $source: { connectionName: "sample_stream_solar" } }, + { $match: { device_id: "device_1" } }, + { + $merge: { + into: { + connectionName: getClusterConnectionName(), + db: "test", + coll: "out", + }, + }, + }, + ], + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("modified"); + }, 30_000); + + it("modify-processor — verify pipeline persisted", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-processor", + workspaceName: getWorkspaceName(), + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBeFalsy(); + expect(content).toContain("device_1"); + }); + + it("update-workspace — changes tier to SP30", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-manage", + arguments: { + projectId: getProjectId(), + workspaceName: getWorkspaceName(), + action: "update-workspace", + newTier: "SP30", + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("updated"); + }, 30_000); + + it("update-workspace — verify via inspect", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "inspect-workspace", + workspaceName: getWorkspaceName(), + }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBeFalsy(); + expect(content).toContain("SP30"); + }); + }); + + describe("atlas-streams-teardown", () => { + it("deletes processor permanently", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-teardown", + arguments: { + projectId: getProjectId(), + resource: "processor", + workspaceName: getWorkspaceName(), + resourceName: processorName, + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("deleted"); + }, 30_000); + }); + }); + + describe("workspace lifecycle", () => { + const lifecycleWsName = `lifecyclews${randomId().slice(0, 8)}`; + let createContent: string | undefined; + let createIsError: boolean | undefined; + + beforeAll(async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-build", + arguments: { + projectId: getProjectId(), + resource: "workspace", + workspaceName: lifecycleWsName, + cloudProvider: "AWS", + region: "VIRGINIA_USA", + includeSampleData: false, + }, + }); + createContent = getResponseContent(response.content); + createIsError = !!response.isError; + }, 120_000); + + afterAll(async () => { + const session = integration.mcpServer().session; + assertApiClientIsAvailable(session); + try { + await session.apiClient.deleteStreamWorkspace({ + params: { + path: { + groupId: getProjectId(), + tenantName: lifecycleWsName, + }, + }, + }); + } catch { + // ignore — teardown test may have already deleted it + } + }); + + it("creates workspace via build tool", () => { + expectDefined(createContent); + expect(createIsError, `Unexpected error: ${createContent}`).toBeFalsy(); + expect(createContent).toContain(lifecycleWsName); + expect(createContent).toContain("created"); + }); + + it("new workspace visible in list-workspaces", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-discover", + arguments: { + projectId: getProjectId(), + action: "list-workspaces", + }, + }); + const content = getResponseContent(response.content); + expect(response.isError).toBeFalsy(); + expect(content).toContain(lifecycleWsName); + }); + + it("deletes workspace via teardown tool", async () => { + const response = await integration.mcpClient().callTool({ + name: "atlas-streams-teardown", + arguments: { + projectId: getProjectId(), + resource: "workspace", + workspaceName: lifecycleWsName, + }, + }); + const content = getResponseContent(response.content); + expect(response.isError, `Unexpected error: ${content}`).toBeFalsy(); + expect(content).toContain("deletion initiated"); + }, 30_000); + }); + }); +}); diff --git a/tests/integration/tools/mongodb/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts index cde443899..401f77d1f 100644 --- a/tests/integration/tools/mongodb/delete/dropIndex.test.ts +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -293,14 +293,17 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( : { database: "mflix", collection: "movies", indexName: getIndexName() }, }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.stringContaining( - "You are about to drop the index named `year_1` from the `mflix.movies` namespace" - ), - mode: "form", - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the index named `year_1` from the `mflix.movies` namespace" + ), + mode: "form", + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }, + { timeout: 300000 } + ); expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(1); }); @@ -319,14 +322,17 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( : { database: "mflix", collection: "movies", indexName: getIndexName() }, }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.stringContaining( - "You are about to drop the index named `year_1` from the `mflix.movies` namespace" - ), - mode: "form", - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the index named `year_1` from the `mflix.movies` namespace" + ), + mode: "form", + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }, + { timeout: 300000 } + ); expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(2); }); }, @@ -547,14 +553,17 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( }, }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.stringContaining( - "You are about to drop the search index named `searchIdx` from the `mflix.movies` namespace" - ), - mode: "form", - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the search index named `searchIdx` from the `mflix.movies` namespace" + ), + mode: "form", + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }, + { timeout: 300000 } + ); expect(dropSearchIndexSpy).toHaveBeenCalledExactlyOnceWith( "mflix", @@ -575,14 +584,17 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( }, }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.stringContaining( - "You are about to drop the search index named `searchIdx` from the `mflix.movies` namespace" - ), - mode: "form", - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + "You are about to drop the search index named `searchIdx` from the `mflix.movies` namespace" + ), + mode: "form", + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }, + { timeout: 300000 } + ); expect(dropSearchIndexSpy).not.toHaveBeenCalled(); }); }, diff --git a/tests/unit/common/config.test.ts b/tests/unit/common/config.test.ts index 73b9ad5d5..4517cff4e 100644 --- a/tests/unit/common/config.test.ts +++ b/tests/unit/common/config.test.ts @@ -34,6 +34,8 @@ const expectedDefaults = { "drop-collection", "delete-many", "drop-index", + "atlas-streams-manage", + "atlas-streams-teardown", ], transport: "stdio", httpPort: 3000, diff --git a/tests/unit/elicitation.test.ts b/tests/unit/elicitation.test.ts index 5a1dc43c0..b13ffee5b 100644 --- a/tests/unit/elicitation.test.ts +++ b/tests/unit/elicitation.test.ts @@ -79,11 +79,14 @@ describe("Elicitation", () => { expect(result).toBe(true); expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: testMessage, - requestedSchema: Elicitation.CONFIRMATION_SCHEMA, - mode: "form", - }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { + message: testMessage, + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + mode: "form", + }, + { timeout: 300000 } + ); }); it("should return false when user selects 'No' with action 'accept'", async () => { @@ -135,4 +138,82 @@ describe("Elicitation", () => { expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); }); }); + + describe("requestInput", () => { + const testMessage = "Please provide connection details."; + const testSchema = { + type: "object" as const, + properties: { + username: { type: "string" as const, title: "Username", description: "Your username" }, + password: { type: "string" as const, title: "Password", description: "Your password" }, + }, + required: ["username", "password"], + }; + + it("should return accepted:false when client does not support elicitation", async () => { + mockGetClientCapabilities.mockReturnValue({}); + + const result = await elicitation.requestInput(testMessage, testSchema); + + expect(result).toEqual({ accepted: false }); + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + }); + + it("should return accepted:true with fields when user accepts", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.acceptWith({ username: "admin", password: "secret" }); + + const result = await elicitation.requestInput(testMessage, testSchema); + + expect(result).toEqual({ accepted: true, fields: { username: "admin", password: "secret" } }); + expect(mockElicitInput.mock).toHaveBeenCalledWith( + { mode: "form", message: testMessage, requestedSchema: testSchema }, + { timeout: 300000 } + ); + }); + + it("should return accepted:false when user cancels", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.cancel(); + + const result = await elicitation.requestInput(testMessage, testSchema); + + expect(result).toEqual({ accepted: false }); + }); + + it("should return accepted:false when action is not accept", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.mock.mockResolvedValue({ action: "decline", content: undefined }); + + const result = await elicitation.requestInput(testMessage, testSchema); + + expect(result).toEqual({ accepted: false }); + }); + + it("should return accepted:false when content is undefined", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.acceptWith(undefined); + + const result = await elicitation.requestInput(testMessage, testSchema); + + expect(result).toEqual({ accepted: false }); + }); + + it("should filter out non-string field values", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.acceptWith({ username: "admin", count: 42, flag: true }); + + const result = await elicitation.requestInput(testMessage, testSchema); + + expect(result).toEqual({ accepted: true, fields: { username: "admin" } }); + }); + + it("should handle elicitInput erroring", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + const error = new Error("Input failed"); + mockElicitInput.rejectWith(error); + + await expect(elicitation.requestInput(testMessage, testSchema)).rejects.toThrow("Input failed"); + }); + }); }); diff --git a/tests/unit/tools/atlas/streams/build.test.ts b/tests/unit/tools/atlas/streams/build.test.ts new file mode 100644 index 000000000..603c11e59 --- /dev/null +++ b/tests/unit/tools/atlas/streams/build.test.ts @@ -0,0 +1,821 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ToolConstructorParams } from "../../../../../src/tools/tool.js"; +import { StreamsBuildTool } from "../../../../../src/tools/atlas/streams/build.js"; +import type { Session } from "../../../../../src/common/session.js"; +import type { UserConfig } from "../../../../../src/common/config/userConfig.js"; +import type { Telemetry } from "../../../../../src/telemetry/telemetry.js"; +import type { Elicitation } from "../../../../../src/elicitation.js"; +import type { CompositeLogger } from "../../../../../src/common/logging/index.js"; +import type { ApiClient } from "../../../../../src/common/atlas/apiClient.js"; +import { UIRegistry } from "../../../../../src/ui/registry/index.js"; +import { MockMetrics } from "../../../mocks/metrics.js"; + +describe("StreamsBuildTool", () => { + let mockApiClient: Record>; + let mockElicitation: { requestConfirmation: ReturnType; requestInput: ReturnType }; + let tool: StreamsBuildTool; + + beforeEach(() => { + mockApiClient = { + createStreamWorkspace: vi.fn().mockResolvedValue({}), + withStreamSampleConnections: vi.fn().mockResolvedValue({}), + createStreamConnection: vi.fn().mockResolvedValue({}), + createStreamProcessor: vi.fn().mockResolvedValue({}), + startStreamProcessor: vi.fn().mockResolvedValue({}), + createPrivateLinkConnection: vi.fn().mockResolvedValue({}), + listStreamConnections: vi.fn().mockResolvedValue({ results: [] }), + }; + + const mockLogger = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + } as unknown as CompositeLogger; + + const mockSession = { + logger: mockLogger, + apiClient: mockApiClient as unknown as ApiClient, + } as unknown as Session; + + const mockConfig = { + confirmationRequiredTools: [], + previewFeatures: ["streams"], + disabledTools: [], + apiClientId: "test-id", + apiClientSecret: "test-secret", + } as unknown as UserConfig; + + const mockTelemetry = { + isTelemetryEnabled: () => true, + emitEvents: vi.fn(), + } as unknown as Telemetry; + + mockElicitation = { + requestConfirmation: vi.fn().mockResolvedValue(true), + requestInput: vi.fn().mockResolvedValue({ accepted: false }), + }; + + const params: ToolConstructorParams = { + name: StreamsBuildTool.toolName, + category: "atlas", + operationType: StreamsBuildTool.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation as unknown as Elicitation, + metrics: new MockMetrics(), + uiRegistry: new UIRegistry(), + }; + + tool = new StreamsBuildTool(params); + }); + + const baseArgs = { projectId: "proj1", workspaceName: "ws1" }; + // Helper to call execute with partial args (tests validate missing fields at runtime) + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const exec = (args: Record) => tool["execute"](args as never); + + describe("createWorkspace", () => { + it("should create workspace with correct provider/region/tier", async () => { + const result = await exec({ + ...baseArgs, + resource: "workspace", + cloudProvider: "AWS", + region: "VIRGINIA_USA", + tier: "SP30", + }); + + expect(mockApiClient.withStreamSampleConnections).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1" } }, + body: { + name: "ws1", + dataProcessRegion: { cloudProvider: "AWS", region: "VIRGINIA_USA" }, + streamConfig: { tier: "SP30" }, + }, + }); + expect((result.content[0] as { text: string }).text).toContain("ws1"); + expect((result.content[0] as { text: string }).text).toContain("AWS/VIRGINIA_USA"); + }); + + it("should default tier to SP10", async () => { + await exec({ + ...baseArgs, + resource: "workspace", + cloudProvider: "AWS", + region: "VIRGINIA_USA", + }); + + expect(mockApiClient.withStreamSampleConnections).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + streamConfig: { tier: "SP10" }, + }), + }) + ); + }); + + it("should skip sample data when includeSampleData is false", async () => { + await exec({ + ...baseArgs, + resource: "workspace", + cloudProvider: "AWS", + region: "VIRGINIA_USA", + includeSampleData: false, + }); + + expect(mockApiClient.createStreamWorkspace).toHaveBeenCalledOnce(); + expect(mockApiClient.withStreamSampleConnections).not.toHaveBeenCalled(); + }); + + it("should throw when cloudProvider is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "workspace", + region: "VIRGINIA_USA", + }) + ).rejects.toThrow("cloudProvider is required"); + }); + + it("should throw when region is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "workspace", + cloudProvider: "AWS", + }) + ).rejects.toThrow("region is required"); + }); + }); + + describe("createProcessor", () => { + it("should create processor with pipeline and DLQ config", async () => { + const pipeline = [ + { $source: { connectionName: "src" } }, + { $merge: { into: { connectionName: "sink", db: "db1", coll: "coll1" } } }, + ]; + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "src" }, { name: "sink" }], + }); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline, + dlq: { connectionName: "sink", db: "db1", coll: "dlq" }, + }); + + expect(mockApiClient.createStreamProcessor).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1", tenantName: "ws1" } }, + body: { + name: "proc1", + pipeline, + options: { dlq: { connectionName: "sink", db: "db1", coll: "dlq" } }, + }, + }); + expect((result.content[0] as { text: string }).text).toContain("proc1"); + }); + + it("should throw when processorName is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "processor", + pipeline: [{ $source: { connectionName: "src" } }], + }) + ).rejects.toThrow("processorName is required"); + }); + + it("should throw when pipeline is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + }) + ).rejects.toThrow("pipeline is required"); + }); + + it("should auto-start processor when autoStart is true", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "src" }, { name: "sink" }], + }); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [ + { $source: { connectionName: "src" } }, + { $merge: { into: { connectionName: "sink", db: "db1", coll: "c1" } } }, + ], + autoStart: true, + }); + + expect(mockApiClient.startStreamProcessor).toHaveBeenCalledOnce(); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("created and started"); + expect(text).toContain("Billing"); + expect(text).toContain("stop-processor"); + }); + }); + + describe("createConnection", () => { + it("should create Kafka connection and normalize bootstrapServers array", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "kafka1", + connectionType: "Kafka", + connectionConfig: { + bootstrapServers: ["broker1:9092", "broker2:9092"], + authentication: { mechanism: "PLAIN", username: "user", password: "pass" }, + security: { protocol: "SASL_SSL" }, + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + bootstrapServers: "broker1:9092,broker2:9092", + name: "kafka1", + type: "Kafka", + }), + }) + ); + }); + + it("should create Cluster connection and set default dbRoleToExecute", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "cluster1", + connectionType: "Cluster", + connectionConfig: { + clusterName: "my-cluster", + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + clusterName: "my-cluster", + dbRoleToExecute: { role: "readWriteAnyDatabase", type: "BUILT_IN" }, + }), + }) + ); + }); + + it("should create S3 connection with roleArn in aws config", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "s3-conn", + connectionType: "S3", + connectionConfig: { + aws: { roleArn: "arn:aws:iam::123456789:role/my-role" }, + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + aws: expect.objectContaining({ roleArn: "arn:aws:iam::123456789:role/my-role" }), + type: "S3", + }), + }) + ); + }); + + it("should trigger elicitation when Kafka missing required fields", async () => { + mockElicitation.requestInput.mockResolvedValue({ accepted: false }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + connectionName: "kafka1", + connectionType: "Kafka", + connectionConfig: {}, + }); + + expect(mockElicitation.requestInput).toHaveBeenCalled(); + expect((result.content[0] as { text: string }).text).toContain("missing"); + expect(mockApiClient.createStreamConnection).not.toHaveBeenCalled(); + }); + + it("should accept elicited fields and proceed with creation", async () => { + mockElicitation.requestInput.mockResolvedValue({ + accepted: true, + fields: { + bootstrapServers: "broker:9092", + mechanism: "PLAIN", + username: "user", + password: "pass", + protocol: "SASL_SSL", + }, + }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + connectionName: "kafka1", + connectionType: "Kafka", + connectionConfig: {}, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledOnce(); + expect((result.content[0] as { text: string }).text).toContain("kafka1"); + }); + + it("should throw when connectionName is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "connection", + connectionType: "Kafka", + }) + ).rejects.toThrow("connectionName is required"); + }); + + it("should throw when connectionType is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "connection", + connectionName: "conn1", + }) + ).rejects.toThrow("connectionType is required"); + }); + }); + + describe("createConnection - Https", () => { + it("should create Https connection with url", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "https1", + connectionType: "Https", + connectionConfig: { url: "https://example.com/api" }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + url: "https://example.com/api", + name: "https1", + type: "Https", + }), + }) + ); + }); + + it("should trigger elicitation when Https url is missing", async () => { + mockElicitation.requestInput.mockResolvedValue({ accepted: false }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + connectionName: "https1", + connectionType: "Https", + connectionConfig: {}, + }); + + expect(mockElicitation.requestInput).toHaveBeenCalled(); + expect((result.content[0] as { text: string }).text).toContain("missing"); + expect(mockApiClient.createStreamConnection).not.toHaveBeenCalled(); + }); + }); + + describe("createConnection - AWSKinesisDataStreams", () => { + it("should create Kinesis connection with roleArn", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "kinesis1", + connectionType: "AWSKinesisDataStreams", + connectionConfig: { + aws: { roleArn: "arn:aws:iam::123:role/my-role" }, + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + aws: expect.objectContaining({ roleArn: "arn:aws:iam::123:role/my-role" }), + type: "AWSKinesisDataStreams", + }), + }) + ); + }); + + it("should trigger elicitation when roleArn is missing", async () => { + mockElicitation.requestInput.mockResolvedValue({ accepted: false }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + connectionName: "kinesis1", + connectionType: "AWSKinesisDataStreams", + connectionConfig: {}, + }); + + expect(mockElicitation.requestInput).toHaveBeenCalled(); + expect((result.content[0] as { text: string }).text).toContain("missing"); + expect((result.content[0] as { text: string }).text).toContain("IAM role ARN"); + }); + }); + + describe("createConnection - AWSLambda", () => { + it("should create Lambda connection with roleArn", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "lambda1", + connectionType: "AWSLambda", + connectionConfig: { + aws: { roleArn: "arn:aws:iam::456:role/lambda-role" }, + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + aws: expect.objectContaining({ roleArn: "arn:aws:iam::456:role/lambda-role" }), + type: "AWSLambda", + }), + }) + ); + }); + }); + + describe("createConnection - SchemaRegistry", () => { + it("should create SchemaRegistry connection with normalized URL and default provider", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "sr1", + connectionType: "SchemaRegistry", + connectionConfig: { + schemaRegistryUrls: ["https://sr.example.com"], + schemaRegistryAuthentication: { + type: "USER_INFO", + username: "user", + password: "pass", + }, + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + type: "SchemaRegistry", + provider: "CONFLUENT", + schemaRegistryUrls: ["https://sr.example.com"], + }), + }) + ); + }); + + it("should normalize single URL string to array", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "sr2", + connectionType: "SchemaRegistry", + connectionConfig: { + schemaRegistryUrls: "https://sr.example.com", + schemaRegistryAuthentication: { + type: "USER_INFO", + username: "user", + password: "pass", + }, + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + schemaRegistryUrls: ["https://sr.example.com"], + }), + }) + ); + }); + + it("should normalize alternative key names (url → schemaRegistryUrls)", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "sr3", + connectionType: "SchemaRegistry", + connectionConfig: { + url: "https://sr.example.com", + username: "user", + password: "pass", + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + schemaRegistryUrls: ["https://sr.example.com"], + schemaRegistryAuthentication: expect.objectContaining({ + type: "USER_INFO", + username: "user", + password: "pass", + }), + }), + }) + ); + }); + + it("should not require username/password when SASL_INHERIT is used", async () => { + await exec({ + ...baseArgs, + resource: "connection", + connectionName: "sr-sasl", + connectionType: "SchemaRegistry", + connectionConfig: { + schemaRegistryUrls: ["https://sr.example.com"], + schemaRegistryAuthentication: { + type: "SASL_INHERIT", + }, + }, + }); + + expect(mockApiClient.createStreamConnection).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + type: "SchemaRegistry", + schemaRegistryAuthentication: expect.objectContaining({ + type: "SASL_INHERIT", + }), + }), + }) + ); + expect(mockElicitation.requestInput).not.toHaveBeenCalled(); + }); + + it("should trigger elicitation when SchemaRegistry URL and auth are missing", async () => { + mockElicitation.requestInput.mockResolvedValue({ accepted: false }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + connectionName: "sr4", + connectionType: "SchemaRegistry", + connectionConfig: {}, + }); + + expect(mockElicitation.requestInput).toHaveBeenCalled(); + expect((result.content[0] as { text: string }).text).toContain("missing"); + }); + }); + + describe("pipeline structural validation", () => { + it("should return error when first stage is not $source", async () => { + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [ + { $match: { status: "active" } }, + { $merge: { into: { connectionName: "sink", db: "db1", coll: "c1" } } }, + ], + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("first stage must be `$source`"); + expect(text).toContain("$match"); + expect(mockApiClient.createStreamProcessor).not.toHaveBeenCalled(); + }); + + it("should return error when last stage is not a terminal stage", async () => { + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [{ $source: { connectionName: "src" } }, { $match: { status: "active" } }], + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("last stage must be a terminal stage"); + expect(text).toContain("$match"); + expect(mockApiClient.createStreamProcessor).not.toHaveBeenCalled(); + }); + + it("should return error when pipeline contains $$NOW", async () => { + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [ + { $source: { connectionName: "src" } }, + { $addFields: { ts: "$$NOW" } }, + { $merge: { into: { connectionName: "sink", db: "db1", coll: "c1" } } }, + ], + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("$$NOW"); + expect(text).toContain("not available in streaming context"); + expect(mockApiClient.createStreamProcessor).not.toHaveBeenCalled(); + }); + + it("should return error when pipeline contains $$ROOT", async () => { + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [ + { $source: { connectionName: "src" } }, + { $replaceRoot: { newRoot: "$$ROOT" } }, + { $emit: { connectionName: "sink", topic: "out" } }, + ], + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("$$ROOT"); + }); + + it("should accept $emit as a valid terminal stage", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "src" }, { name: "sink" }], + }); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [{ $source: { connectionName: "src" } }, { $emit: { connectionName: "sink", topic: "out" } }], + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiClient.createStreamProcessor).toHaveBeenCalledOnce(); + }); + + it("should accept $https as a valid terminal stage", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "src" }, { name: "webhook" }], + }); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [{ $source: { connectionName: "src" } }, { $https: { connectionName: "webhook" } }], + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiClient.createStreamProcessor).toHaveBeenCalledOnce(); + }); + + it("should accept $externalFunction as a valid terminal stage", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "src" }, { name: "lambda" }], + }); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [{ $source: { connectionName: "src" } }, { $externalFunction: { connectionName: "lambda" } }], + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiClient.createStreamProcessor).toHaveBeenCalledOnce(); + }); + }); + + describe("pipeline connection validation", () => { + it("should return error when pipeline references non-existent connections", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "existing-conn" }], + }); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [ + { $source: { connectionName: "missing-conn" } }, + { $merge: { into: { connectionName: "existing-conn", db: "db1", coll: "c1" } } }, + ], + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("missing-conn"); + expect(text).toContain("do not exist"); + }); + + it("should succeed when all pipeline connections exist", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "src" }, { name: "sink" }], + }); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [ + { $source: { connectionName: "src" } }, + { $merge: { into: { connectionName: "sink", db: "db1", coll: "c1" } } }, + ], + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiClient.createStreamProcessor).toHaveBeenCalledOnce(); + }); + + it("should skip validation when connection list API fails", async () => { + mockApiClient.listStreamConnections!.mockRejectedValue(new Error("API error")); + + const result = await exec({ + ...baseArgs, + resource: "processor", + processorName: "proc1", + pipeline: [ + { $source: { connectionName: "src" } }, + { $merge: { into: { connectionName: "sink", db: "db1", coll: "c1" } } }, + ], + }); + + expect(result.isError).toBeUndefined(); + expect(mockApiClient.createStreamProcessor).toHaveBeenCalledOnce(); + }); + }); + + describe("createPrivateLink", () => { + it("should create PrivateLink connection with correct params", async () => { + await exec({ + ...baseArgs, + resource: "privatelink", + privateLinkProvider: "AWS", + privateLinkConfig: { + region: "us-east-1", + vendor: "AWS", + arn: "arn:aws:...", + dnsDomain: "example.com", + dnsSubDomain: "sub", + }, + }); + + expect(mockApiClient.createPrivateLinkConnection).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1" } }, + body: { + provider: "AWS", + region: "us-east-1", + vendor: "AWS", + arn: "arn:aws:...", + dnsDomain: "example.com", + dnsSubDomain: "sub", + }, + }); + }); + + it("should not allow privateLinkConfig.provider to overwrite privateLinkProvider", async () => { + await exec({ + ...baseArgs, + resource: "privatelink", + privateLinkProvider: "AWS", + privateLinkConfig: { + provider: "GCP", + region: "us-east-1", + }, + }); + + expect(mockApiClient.createPrivateLinkConnection).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1" } }, + body: expect.objectContaining({ + provider: "AWS", + }), + }); + }); + + it("should throw when privateLinkProvider is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "privatelink", + privateLinkConfig: { region: "us-east-1" }, + }) + ).rejects.toThrow("privateLinkProvider is required"); + }); + + it("should throw when privateLinkConfig is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "privatelink", + privateLinkProvider: "AWS", + }) + ).rejects.toThrow("privateLinkConfig is required"); + }); + }); +}); diff --git a/tests/unit/tools/atlas/streams/discover.test.ts b/tests/unit/tools/atlas/streams/discover.test.ts new file mode 100644 index 000000000..a11a61bed --- /dev/null +++ b/tests/unit/tools/atlas/streams/discover.test.ts @@ -0,0 +1,562 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ToolConstructorParams } from "../../../../../src/tools/tool.js"; +import { StreamsDiscoverTool } from "../../../../../src/tools/atlas/streams/discover.js"; +import type { Session } from "../../../../../src/common/session.js"; +import type { UserConfig } from "../../../../../src/common/config/userConfig.js"; +import type { Telemetry } from "../../../../../src/telemetry/telemetry.js"; +import type { Elicitation } from "../../../../../src/elicitation.js"; +import type { CompositeLogger } from "../../../../../src/common/logging/index.js"; +import type { ApiClient } from "../../../../../src/common/atlas/apiClient.js"; +import { UIRegistry } from "../../../../../src/ui/registry/index.js"; +import { MockMetrics } from "../../../mocks/metrics.js"; + +describe("StreamsDiscoverTool", () => { + let mockApiClient: Record>; + let tool: StreamsDiscoverTool; + + beforeEach(() => { + mockApiClient = { + listStreamWorkspaces: vi.fn(), + getStreamWorkspace: vi.fn(), + listStreamConnections: vi.fn(), + getStreamConnection: vi.fn(), + getStreamProcessors: vi.fn(), + getStreamProcessor: vi.fn(), + downloadOperationalLogs: vi.fn(), + downloadAuditLogs: vi.fn(), + listPrivateLinkConnections: vi.fn(), + getAccountDetails: vi.fn(), + }; + + const mockLogger = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + } as unknown as CompositeLogger; + + const mockSession = { + logger: mockLogger, + apiClient: mockApiClient as unknown as ApiClient, + } as unknown as Session; + + const mockConfig = { + confirmationRequiredTools: [], + previewFeatures: ["streams"], + disabledTools: [], + apiClientId: "test-id", + apiClientSecret: "test-secret", + } as unknown as UserConfig; + + const mockTelemetry = { + isTelemetryEnabled: () => true, + emitEvents: vi.fn(), + } as unknown as Telemetry; + + const mockElicitation = { + requestConfirmation: vi.fn(), + } as unknown as Elicitation; + + const params: ToolConstructorParams = { + name: StreamsDiscoverTool.toolName, + category: "atlas", + operationType: StreamsDiscoverTool.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + metrics: new MockMetrics(), + uiRegistry: new UIRegistry(), + }; + + tool = new StreamsDiscoverTool(params); + }); + + const baseArgs = { projectId: "proj1" }; + // Helper to call execute with partial args (tests validate missing fields at runtime) + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const exec = (args: Record) => tool["execute"](args as never); + + describe("list-workspaces", () => { + it("should return workspace list when workspaces exist", async () => { + mockApiClient.listStreamWorkspaces!.mockResolvedValue({ + results: [ + { + name: "ws1", + dataProcessRegion: { cloudProvider: "AWS", region: "VIRGINIA_USA" }, + streamConfig: { tier: "SP10", maxTierSize: "SP50" }, + }, + ], + totalCount: 1, + }); + + const result = await exec({ ...baseArgs, action: "list-workspaces" }); + + expect(result.content).toBeDefined(); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("1 workspace(s)"); + }); + + it("should return empty message when no workspaces", async () => { + mockApiClient.listStreamWorkspaces!.mockResolvedValue({ results: [] }); + + const result = await exec({ ...baseArgs, action: "list-workspaces" }); + + expect((result.content[0] as { text: string }).text).toContain("No Stream Processing workspaces"); + }); + + it("should pass limit and pageNum to API", async () => { + mockApiClient.listStreamWorkspaces!.mockResolvedValue({ results: [], totalCount: 0 }); + + await exec({ ...baseArgs, action: "list-workspaces", limit: 5, pageNum: 2 }); + + expect(mockApiClient.listStreamWorkspaces).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1" }, query: { itemsPerPage: 5, pageNum: 2 } }, + }); + }); + }); + + describe("inspect-workspace", () => { + it("should throw when workspaceName is not provided", async () => { + await expect(exec({ ...baseArgs, action: "inspect-workspace" })).rejects.toThrow( + "workspaceName is required" + ); + }); + + it("should return workspace details", async () => { + mockApiClient.getStreamWorkspace!.mockResolvedValue({ + name: "ws1", + dataProcessRegion: { cloudProvider: "AWS", region: "VIRGINIA_USA" }, + streamConfig: { tier: "SP10" }, + connections: [{ name: "c1" }], + }); + + const result = await exec({ + ...baseArgs, + action: "inspect-workspace", + workspaceName: "ws1", + }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("ws1"); + }); + }); + + describe("diagnose-processor", () => { + it("should combine processor state, stats, and connection health in report", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ + name: "proc1", + state: "STARTED", + tier: "SP10", + stats: { inputMessageCount: 100, outputMessageCount: 90, dlqMessageCount: 10 }, + pipeline: [{ $source: { connectionName: "kafka-in" } }], + options: { dlq: { connectionName: "cluster", db: "mydb", coll: "dlq" } }, + }); + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [ + { name: "kafka-in", type: "Kafka", state: "ACTIVE" }, + { name: "cluster", type: "Cluster", state: "ACTIVE" }, + ], + }); + + const result = await exec({ + ...baseArgs, + action: "diagnose-processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("Processor State"); + expect(text).toContain("proc1"); + expect(text).toContain("STARTED"); + expect(text).toContain("Connection Health"); + expect(text).toContain("Dead Letter Queue"); + }); + + it("should throw when resourceName is not provided", async () => { + await expect( + exec({ + ...baseArgs, + action: "diagnose-processor", + workspaceName: "ws1", + }) + ).rejects.toThrow("resourceName is required"); + }); + + it("should show DLQ warning when all messages go to DLQ", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ + name: "proc1", + state: "STARTED", + tier: "SP10", + stats: { inputMessageCount: 100, outputMessageCount: 0, dlqMessageCount: 100 }, + pipeline: [{ $source: { connectionName: "src" } }], + }); + mockApiClient.listStreamConnections!.mockResolvedValue({ results: [] }); + + const result = await exec({ + ...baseArgs, + action: "diagnose-processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("All 100 input messages went to DLQ"); + expect(text).toContain("Health Warning"); + }); + + it("should show high-DLQ-ratio warning when over 50% fail", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ + name: "proc1", + state: "STARTED", + tier: "SP10", + stats: { inputMessageCount: 100, outputMessageCount: 30, dlqMessageCount: 70 }, + pipeline: [{ $source: { connectionName: "src" } }], + }); + mockApiClient.listStreamConnections!.mockResolvedValue({ results: [] }); + + const result = await exec({ + ...baseArgs, + action: "diagnose-processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("70% of messages going to DLQ"); + expect(text).toContain("Health Warning"); + }); + + it("should skip health analysis when stats are empty", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ + name: "proc1", + state: "STARTED", + tier: "SP10", + stats: {}, + pipeline: [{ $source: { connectionName: "src" } }], + }); + mockApiClient.listStreamConnections!.mockResolvedValue({ results: [] }); + + const result = await exec({ + ...baseArgs, + action: "diagnose-processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("Processor State"); + expect(text).not.toContain("Processor Stats"); + }); + + it("should handle processor fetch failure gracefully", async () => { + mockApiClient.getStreamProcessor!.mockRejectedValue(new Error("API timeout")); + mockApiClient.listStreamConnections!.mockResolvedValue({ results: [] }); + + const result = await exec({ + ...baseArgs, + action: "diagnose-processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("Error fetching processor"); + }); + }); + + describe("list-connections", () => { + it("should return connection list when connections exist", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [ + { name: "kafka-in", type: "Kafka", state: "ACTIVE" }, + { name: "cluster-out", type: "Cluster", state: "ACTIVE" }, + ], + }); + + const result = await exec({ + ...baseArgs, + action: "list-connections", + workspaceName: "ws1", + }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("2 connection(s)"); + }); + + it("should return empty message when no connections", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ results: [] }); + + const result = await exec({ + ...baseArgs, + action: "list-connections", + workspaceName: "ws1", + }); + + expect((result.content[0] as { text: string }).text).toContain("No connections found"); + }); + + it("should pass limit and pageNum to API", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ results: [] }); + + await exec({ + ...baseArgs, + action: "list-connections", + workspaceName: "ws1", + limit: 10, + pageNum: 3, + }); + + expect(mockApiClient.listStreamConnections).toHaveBeenCalledWith({ + params: { + path: { groupId: "proj1", tenantName: "ws1" }, + query: { itemsPerPage: 10, pageNum: 3 }, + }, + }); + }); + + it("should throw when workspaceName is missing", async () => { + await expect(exec({ ...baseArgs, action: "list-connections" })).rejects.toThrow( + "workspaceName is required" + ); + }); + }); + + describe("inspect-connection", () => { + it("should return connection details", async () => { + mockApiClient.getStreamConnection!.mockResolvedValue({ + name: "kafka-in", + type: "Kafka", + bootstrapServers: "broker:9092", + }); + + const result = await exec({ + ...baseArgs, + action: "inspect-connection", + workspaceName: "ws1", + resourceName: "kafka-in", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("kafka-in"); + expect(text).toContain("ws1"); + }); + + it("should add note when Cluster connection name differs from clusterName", async () => { + mockApiClient.getStreamConnection!.mockResolvedValue({ + name: "my-conn", + type: "Cluster", + clusterName: "actual-cluster", + }); + + const result = await exec({ + ...baseArgs, + action: "inspect-connection", + workspaceName: "ws1", + resourceName: "my-conn", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("Note"); + expect(text).toContain("my-conn"); + expect(text).toContain("actual-cluster"); + }); + + it("should throw when resourceName is missing", async () => { + await expect(exec({ ...baseArgs, action: "inspect-connection", workspaceName: "ws1" })).rejects.toThrow( + "resourceName is required" + ); + }); + }); + + describe("list-processors", () => { + it("should return processor list when processors exist", async () => { + mockApiClient.getStreamProcessors!.mockResolvedValue({ + results: [ + { name: "proc1", state: "STARTED", tier: "SP10" }, + { name: "proc2", state: "STOPPED", tier: "SP30" }, + ], + }); + + const result = await exec({ + ...baseArgs, + action: "list-processors", + workspaceName: "ws1", + }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("2 processor(s)"); + }); + + it("should return empty message when no processors", async () => { + mockApiClient.getStreamProcessors!.mockResolvedValue({ results: [] }); + + const result = await exec({ + ...baseArgs, + action: "list-processors", + workspaceName: "ws1", + }); + + expect((result.content[0] as { text: string }).text).toContain("No processors found"); + }); + + it("should throw when workspaceName is missing", async () => { + await expect(exec({ ...baseArgs, action: "list-processors" })).rejects.toThrow("workspaceName is required"); + }); + }); + + describe("inspect-processor", () => { + it("should return processor details", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ + name: "proc1", + state: "STARTED", + tier: "SP10", + pipeline: [{ $source: { connectionName: "kafka-in" } }], + }); + + const result = await exec({ + ...baseArgs, + action: "inspect-processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("proc1"); + expect(text).toContain("ws1"); + }); + + it("should throw when resourceName is missing", async () => { + await expect(exec({ ...baseArgs, action: "inspect-processor", workspaceName: "ws1" })).rejects.toThrow( + "resourceName is required" + ); + }); + }); + + describe("get-logs", () => { + it("should decompress and return operational logs", async () => { + const { gzipSync } = await import("node:zlib"); + const logData = "2024-01-01 log line 1\n2024-01-01 log line 2\n"; + const compressed = gzipSync(Buffer.from(logData)); + const arrayBuffer = compressed.buffer.slice( + compressed.byteOffset, + compressed.byteOffset + compressed.byteLength + ); + mockApiClient.downloadOperationalLogs!.mockResolvedValue(arrayBuffer); + + const result = await exec({ + ...baseArgs, + action: "get-logs", + workspaceName: "ws1", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("Operational logs"); + expect(text).toContain("log line 1"); + }); + + it("should use audit log API when logType is audit", async () => { + const { gzipSync } = await import("node:zlib"); + const logData = "audit entry\n"; + const compressed = gzipSync(Buffer.from(logData)); + const arrayBuffer = compressed.buffer.slice( + compressed.byteOffset, + compressed.byteOffset + compressed.byteLength + ); + mockApiClient.downloadAuditLogs!.mockResolvedValue(arrayBuffer); + + const result = await exec({ + ...baseArgs, + action: "get-logs", + workspaceName: "ws1", + logType: "audit", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("Audit logs"); + expect(mockApiClient.downloadAuditLogs).toHaveBeenCalledOnce(); + }); + + it("should return no-data message when API returns null", async () => { + mockApiClient.downloadOperationalLogs!.mockResolvedValue(null); + + const result = await exec({ + ...baseArgs, + action: "get-logs", + workspaceName: "ws1", + }); + + expect((result.content[0] as { text: string }).text).toContain("No logs available"); + }); + + it("should return fallback message when decompression fails", async () => { + mockApiClient.downloadOperationalLogs!.mockResolvedValue(new ArrayBuffer(10)); + + const result = await exec({ + ...baseArgs, + action: "get-logs", + workspaceName: "ws1", + }); + + expect((result.content[0] as { text: string }).text).toContain("Could not decompress"); + }); + }); + + describe("get-networking", () => { + it("should return PrivateLink details", async () => { + mockApiClient.listPrivateLinkConnections!.mockResolvedValue({ + results: [{ _id: "pl-1", provider: "AWS", region: "us-east-1", state: "AVAILABLE", vendor: "AWS" }], + }); + + const result = await exec({ + ...baseArgs, + action: "get-networking", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("PrivateLink"); + expect(text).toContain("pl-1"); + }); + + it("should include account details when cloudProvider and region are provided", async () => { + mockApiClient.listPrivateLinkConnections!.mockResolvedValue({ results: [] }); + mockApiClient.getAccountDetails!.mockResolvedValue({ awsAccountId: "123456789" }); + + const result = await exec({ + ...baseArgs, + action: "get-networking", + cloudProvider: "AWS", + region: "VIRGINIA_USA", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("Account Details"); + expect(text).toContain("123456789"); + }); + + it("should handle empty networking results", async () => { + mockApiClient.listPrivateLinkConnections!.mockResolvedValue({ results: [] }); + + const result = await exec({ + ...baseArgs, + action: "get-networking", + }); + + const text = result.content.map((c) => (c as { text: string }).text).join("\n"); + expect(text).toContain("No PrivateLink connections found"); + }); + }); + + describe("unknown action", () => { + it("should return error for unknown action", async () => { + const result = await exec({ + ...baseArgs, + action: "nonexistent" as never, + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Unknown action"); + }); + }); +}); diff --git a/tests/unit/tools/atlas/streams/manage.test.ts b/tests/unit/tools/atlas/streams/manage.test.ts new file mode 100644 index 000000000..025d1d04a --- /dev/null +++ b/tests/unit/tools/atlas/streams/manage.test.ts @@ -0,0 +1,630 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ToolConstructorParams } from "../../../../../src/tools/tool.js"; +import { StreamsManageTool } from "../../../../../src/tools/atlas/streams/manage.js"; +import type { Session } from "../../../../../src/common/session.js"; +import type { UserConfig } from "../../../../../src/common/config/userConfig.js"; +import type { Telemetry } from "../../../../../src/telemetry/telemetry.js"; +import type { Elicitation } from "../../../../../src/elicitation.js"; +import type { CompositeLogger } from "../../../../../src/common/logging/index.js"; +import type { ApiClient } from "../../../../../src/common/atlas/apiClient.js"; +import { UIRegistry } from "../../../../../src/ui/registry/index.js"; +import { MockMetrics } from "../../../mocks/metrics.js"; + +describe("StreamsManageTool", () => { + let mockApiClient: Record>; + let tool: StreamsManageTool; + + beforeEach(() => { + mockApiClient = { + getStreamProcessor: vi.fn(), + startStreamProcessor: vi.fn().mockResolvedValue({}), + startStreamProcessorWith: vi.fn().mockResolvedValue({}), + stopStreamProcessor: vi.fn().mockResolvedValue({}), + updateStreamProcessor: vi.fn().mockResolvedValue({}), + getStreamWorkspace: vi.fn().mockResolvedValue({ streamConfig: { maxTierSize: "SP50" } }), + updateStreamWorkspace: vi.fn().mockResolvedValue({}), + updateStreamConnection: vi.fn().mockResolvedValue({}), + acceptVpcPeeringConnection: vi.fn().mockResolvedValue({}), + rejectVpcPeeringConnection: vi.fn().mockResolvedValue({}), + }; + + const mockLogger = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + } as unknown as CompositeLogger; + + const mockSession = { + logger: mockLogger, + apiClient: mockApiClient as unknown as ApiClient, + } as unknown as Session; + + const mockConfig = { + confirmationRequiredTools: [], + previewFeatures: ["streams"], + disabledTools: [], + apiClientId: "test-id", + apiClientSecret: "test-secret", + } as unknown as UserConfig; + + const mockTelemetry = { + isTelemetryEnabled: () => true, + emitEvents: vi.fn(), + } as unknown as Telemetry; + + const mockElicitation = { + requestConfirmation: vi.fn().mockResolvedValue(true), + } as unknown as Elicitation; + + const params: ToolConstructorParams = { + name: StreamsManageTool.toolName, + category: "atlas", + operationType: StreamsManageTool.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + metrics: new MockMetrics(), + uiRegistry: new UIRegistry(), + }; + + tool = new StreamsManageTool(params); + }); + + const baseArgs = { projectId: "proj1", workspaceName: "ws1" }; + // Helper to call execute with partial args (tests validate missing fields at runtime) + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const exec = (args: Record) => tool["execute"](args as never); + + describe("start-processor", () => { + it("should start a STOPPED processor", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + }); + + expect(mockApiClient.startStreamProcessor).toHaveBeenCalledOnce(); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("started"); + expect(text).toContain("Billing"); + expect(text).toContain("stop-processor"); + }); + + it("should return already-running message for STARTED processor", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STARTED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("already running"); + expect(mockApiClient.startStreamProcessor).not.toHaveBeenCalled(); + }); + + it("should use startStreamProcessorWith when tier override is provided", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + tier: "SP30", + }); + + expect(mockApiClient.startStreamProcessorWith).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ tier: "SP30" }), + }) + ); + expect(mockApiClient.startStreamProcessor).not.toHaveBeenCalled(); + }); + + it("should use startStreamProcessorWith when resumeFromCheckpoint is set", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + resumeFromCheckpoint: false, + }); + + expect(mockApiClient.startStreamProcessorWith).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ resumeFromCheckpoint: false }), + }) + ); + }); + + it("should throw when resourceName is missing", async () => { + await expect( + exec({ + ...baseArgs, + action: "start-processor", + }) + ).rejects.toThrow("resourceName is required"); + }); + + it("should return error when tier exceeds workspace max tier", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + mockApiClient.getStreamWorkspace!.mockResolvedValue({ streamConfig: { maxTierSize: "SP10" } }); + + const result = await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + tier: "SP50", + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("Cannot start processor"); + expect(text).toContain("SP50"); + expect(text).toContain("SP10"); + expect(mockApiClient.startStreamProcessor).not.toHaveBeenCalled(); + expect(mockApiClient.startStreamProcessorWith).not.toHaveBeenCalled(); + }); + + it("should proceed when tier is within workspace max", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + mockApiClient.getStreamWorkspace!.mockResolvedValue({ streamConfig: { maxTierSize: "SP50" } }); + + await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + tier: "SP30", + }); + + expect(mockApiClient.startStreamProcessorWith).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ tier: "SP30" }), + }) + ); + }); + + it("should proceed with tier when workspace fetch fails (soft check)", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + mockApiClient.getStreamWorkspace!.mockRejectedValue(new Error("API error")); + + await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + tier: "SP50", + }); + + expect(mockApiClient.startStreamProcessorWith).toHaveBeenCalled(); + }); + + it("should use startStreamProcessorWith when startAtOperationTime is set", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + startAtOperationTime: "2026-01-01T00:00:00Z", + }); + + expect(mockApiClient.startStreamProcessorWith).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ startAtOperationTime: "2026-01-01T00:00:00Z" }), + }) + ); + }); + + it("should include no-checkpoint note when resumeFromCheckpoint is false", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + resumeFromCheckpoint: false, + }); + + expect((result.content[0] as { text: string }).text).toContain("from the beginning"); + }); + }); + + describe("stop-processor", () => { + it("should stop a STARTED processor", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STARTED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "stop-processor", + resourceName: "proc1", + }); + + expect(mockApiClient.stopStreamProcessor).toHaveBeenCalledOnce(); + expect((result.content[0] as { text: string }).text).toContain("stopped"); + }); + + it("should return already-stopped message for STOPPED processor", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "stop-processor", + resourceName: "proc1", + }); + + expect((result.content[0] as { text: string }).text).toContain("already stopped"); + expect(mockApiClient.stopStreamProcessor).not.toHaveBeenCalled(); + }); + }); + + describe("modify-processor", () => { + it("should return error when processor is STARTED", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STARTED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "modify-processor", + resourceName: "proc1", + pipeline: [{ $source: { connectionName: "src" } }], + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("must be stopped"); + expect(mockApiClient.updateStreamProcessor).not.toHaveBeenCalled(); + }); + + it("should update processor with new pipeline when STOPPED", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + const newPipeline = [{ $source: { connectionName: "new-src" } }]; + + const result = await exec({ + ...baseArgs, + action: "modify-processor", + resourceName: "proc1", + pipeline: newPipeline, + }); + + expect(mockApiClient.updateStreamProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ pipeline: newPipeline }), + }) + ); + expect((result.content[0] as { text: string }).text).toContain("modified"); + }); + + it("should return error when no modifications specified", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "modify-processor", + resourceName: "proc1", + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("No modifications"); + }); + + it("should rename processor via newName", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + + const result = await exec({ + ...baseArgs, + action: "modify-processor", + resourceName: "proc1", + newName: "proc1-renamed", + }); + + expect(mockApiClient.updateStreamProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ name: "proc1-renamed" }), + }) + ); + expect((result.content[0] as { text: string }).text).toContain("modified"); + expect((result.content[0] as { text: string }).text).toContain("name"); + }); + + it("should update only DLQ config", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + const dlq = { connectionName: "cluster", db: "mydb", coll: "dlq" }; + + const result = await exec({ + ...baseArgs, + action: "modify-processor", + resourceName: "proc1", + dlq, + }); + + expect(mockApiClient.updateStreamProcessor).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ options: { dlq } }), + }) + ); + expect((result.content[0] as { text: string }).text).toContain("modified"); + expect((result.content[0] as { text: string }).text).toContain("options"); + }); + }); + + describe("update-workspace", () => { + it("should update workspace with new tier", async () => { + const result = await exec({ + ...baseArgs, + action: "update-workspace", + newTier: "SP30", + }); + + expect(mockApiClient.updateStreamWorkspace).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1", tenantName: "ws1" } }, + body: { streamConfig: { tier: "SP30" } }, + }); + expect((result.content[0] as { text: string }).text).toContain("updated"); + }); + + it("should return error when no updates specified", async () => { + const result = await exec({ + ...baseArgs, + action: "update-workspace", + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("No updates specified"); + }); + + it("should update region only", async () => { + const result = await exec({ + ...baseArgs, + action: "update-workspace", + newRegion: "OREGON_USA", + }); + + expect(mockApiClient.updateStreamWorkspace).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1", tenantName: "ws1" } }, + body: { dataProcessRegion: { region: "OREGON_USA" } }, + }); + expect((result.content[0] as { text: string }).text).toContain("updated"); + }); + }); + + describe("update-connection", () => { + it("should update connection with new config", async () => { + const result = await exec({ + ...baseArgs, + action: "update-connection", + resourceName: "conn1", + connectionConfig: { bootstrapServers: "new-broker:9092" }, + }); + + expect(mockApiClient.updateStreamConnection).toHaveBeenCalledOnce(); + expect((result.content[0] as { text: string }).text).toContain("conn1"); + expect((result.content[0] as { text: string }).text).toContain("updated"); + }); + + it("should throw when connectionConfig is missing", async () => { + await expect( + exec({ + ...baseArgs, + action: "update-connection", + resourceName: "conn1", + }) + ).rejects.toThrow("connectionConfig is required"); + }); + + it("should throw when resourceName is missing", async () => { + await expect( + exec({ + ...baseArgs, + action: "update-connection", + connectionConfig: { bootstrapServers: "broker:9092" }, + }) + ).rejects.toThrow("resourceName is required"); + }); + }); + + describe("accept-peering", () => { + it("should call correct API with peering params", async () => { + const result = await exec({ + ...baseArgs, + action: "accept-peering", + peeringId: "peer-1", + requesterAccountId: "123456789", + requesterVpcId: "vpc-abc", + }); + + expect(mockApiClient.acceptVpcPeeringConnection).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1", id: "peer-1" } }, + body: { requesterAccountId: "123456789", requesterVpcId: "vpc-abc" }, + }); + expect((result.content[0] as { text: string }).text).toContain("accepted"); + }); + + it("should throw when peeringId is missing", async () => { + await expect( + exec({ + ...baseArgs, + action: "accept-peering", + requesterAccountId: "123", + requesterVpcId: "vpc-1", + }) + ).rejects.toThrow("peeringId is required"); + }); + + it("should throw when requesterAccountId is missing", async () => { + await expect( + exec({ + ...baseArgs, + action: "accept-peering", + peeringId: "peer-1", + requesterVpcId: "vpc-1", + }) + ).rejects.toThrow("requesterAccountId is required"); + }); + + it("should throw when requesterVpcId is missing", async () => { + await expect( + exec({ + ...baseArgs, + action: "accept-peering", + peeringId: "peer-1", + requesterAccountId: "123", + }) + ).rejects.toThrow("requesterVpcId is required"); + }); + }); + + describe("getConfirmationMessage", () => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const confirmMsg = (args: Record) => tool["getConfirmationMessage"](args as never); + + it("should include billing warning for start-processor", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + }); + expect(msg).toContain("billing"); + expect(msg).toContain("proc1"); + }); + + it("should include checkpoint loss warning when resumeFromCheckpoint is false", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + resumeFromCheckpoint: false, + }); + expect(msg).toContain("resumeFromCheckpoint is false"); + expect(msg).toContain("window state will be permanently lost"); + }); + + it("should not include checkpoint warning when resumeFromCheckpoint is true or unset", () => { + const msgTrue = confirmMsg({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + resumeFromCheckpoint: true, + }); + expect(msgTrue).not.toContain("window state will be permanently lost"); + + const msgUnset = confirmMsg({ + ...baseArgs, + action: "start-processor", + resourceName: "proc1", + }); + expect(msgUnset).not.toContain("window state will be permanently lost"); + }); + + it("should mention in-flight data for stop-processor", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "stop-processor", + resourceName: "proc1", + }); + expect(msg).toContain("In-flight data"); + expect(msg).toContain("proc1"); + }); + + it("should warn about pipeline changes for modify-processor", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "modify-processor", + resourceName: "proc1", + }); + expect(msg).toContain("modify"); + expect(msg).toContain("proc1"); + }); + + it("should mention workspace update for update-workspace", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "update-workspace", + }); + expect(msg).toContain("update workspace"); + expect(msg).toContain("ws1"); + }); + + it("should mention connection update for update-connection", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "update-connection", + resourceName: "conn1", + }); + expect(msg).toContain("update connection"); + expect(msg).toContain("conn1"); + }); + + it("should mention peering ID for accept-peering", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "accept-peering", + peeringId: "peer-1", + }); + expect(msg).toContain("accept"); + expect(msg).toContain("peer-1"); + }); + + it("should warn about irreversibility for reject-peering", () => { + const msg = confirmMsg({ + ...baseArgs, + action: "reject-peering", + peeringId: "peer-1", + }); + expect(msg).toContain("reject"); + expect(msg).toContain("cannot be undone"); + expect(msg).toContain("peer-1"); + }); + + it("should throw when resourceName is missing for processor actions", () => { + for (const action of ["start-processor", "stop-processor", "modify-processor", "update-connection"]) { + expect(() => confirmMsg({ ...baseArgs, action })).toThrow("resourceName is required"); + } + }); + + it("should throw when peeringId is missing for peering actions", () => { + expect(() => confirmMsg({ ...baseArgs, action: "accept-peering" })).toThrow("peeringId is required"); + expect(() => confirmMsg({ ...baseArgs, action: "reject-peering" })).toThrow("peeringId is required"); + }); + }); + + describe("reject-peering", () => { + it("should call correct API", async () => { + const result = await exec({ + ...baseArgs, + action: "reject-peering", + peeringId: "peer-1", + }); + + expect(mockApiClient.rejectVpcPeeringConnection).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1", id: "peer-1" } }, + }); + expect((result.content[0] as { text: string }).text).toContain("rejected"); + }); + + it("should throw when peeringId is missing", async () => { + await expect( + exec({ + ...baseArgs, + action: "reject-peering", + }) + ).rejects.toThrow("peeringId is required"); + }); + }); + + describe("unknown action", () => { + it("should return error for unknown action", async () => { + const result = await exec({ + ...baseArgs, + action: "unknown-action", + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Unknown action"); + }); + }); +}); diff --git a/tests/unit/tools/atlas/streams/streamsArgs.test.ts b/tests/unit/tools/atlas/streams/streamsArgs.test.ts new file mode 100644 index 000000000..66a0e1715 --- /dev/null +++ b/tests/unit/tools/atlas/streams/streamsArgs.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { StreamsArgs } from "../../../../../src/tools/atlas/streams/streamsArgs.js"; + +describe("StreamsArgs", () => { + describe("workspaceName", () => { + const schema = StreamsArgs.workspaceName(); + + it("should accept valid names", () => { + expect(schema.safeParse("my-workspace").success).toBe(true); + expect(schema.safeParse("ws_123").success).toBe(true); + expect(schema.safeParse("A").success).toBe(true); + expect(schema.safeParse("a".repeat(64)).success).toBe(true); + }); + + it("should reject empty string", () => { + expect(schema.safeParse("").success).toBe(false); + }); + + it("should reject names longer than 64 characters", () => { + expect(schema.safeParse("a".repeat(65)).success).toBe(false); + }); + + it("should reject names with special characters", () => { + expect(schema.safeParse("my workspace!").success).toBe(false); + expect(schema.safeParse("ws@name").success).toBe(false); + expect(schema.safeParse("ws.name").success).toBe(false); + }); + }); + + describe("processorName", () => { + const schema = StreamsArgs.processorName(); + + it("should accept valid names", () => { + expect(schema.safeParse("my-processor").success).toBe(true); + expect(schema.safeParse("proc_1").success).toBe(true); + }); + + it("should reject empty string", () => { + expect(schema.safeParse("").success).toBe(false); + }); + + it("should reject names with spaces", () => { + expect(schema.safeParse("my processor").success).toBe(false); + }); + }); + + describe("connectionName", () => { + const schema = StreamsArgs.connectionName(); + + it("should accept valid names", () => { + expect(schema.safeParse("kafka-conn").success).toBe(true); + expect(schema.safeParse("conn_1").success).toBe(true); + }); + + it("should reject empty string", () => { + expect(schema.safeParse("").success).toBe(false); + }); + + it("should reject names longer than 64 characters", () => { + expect(schema.safeParse("c".repeat(65)).success).toBe(false); + }); + }); +}); diff --git a/tests/unit/tools/atlas/streams/streamsToolBase.test.ts b/tests/unit/tools/atlas/streams/streamsToolBase.test.ts new file mode 100644 index 000000000..c463daeec --- /dev/null +++ b/tests/unit/tools/atlas/streams/streamsToolBase.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { z } from "zod"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs, ToolConstructorParams } from "../../../../../src/tools/tool.js"; +import { StreamsToolBase } from "../../../../../src/tools/atlas/streams/streamsToolBase.js"; +import { ApiClientError } from "../../../../../src/common/atlas/apiClientError.js"; +import type { Session } from "../../../../../src/common/session.js"; +import type { UserConfig } from "../../../../../src/common/config/userConfig.js"; +import type { Telemetry } from "../../../../../src/telemetry/telemetry.js"; +import type { Elicitation } from "../../../../../src/elicitation.js"; +import type { CompositeLogger } from "../../../../../src/common/logging/index.js"; +import type { TelemetryToolMetadata } from "../../../../../src/telemetry/types.js"; +import { UIRegistry } from "../../../../../src/ui/registry/index.js"; +import { MockMetrics } from "../../../mocks/metrics.js"; + +class TestStreamsTool extends StreamsToolBase { + static toolName = "test-streams-tool"; + static operationType: OperationType = "read"; + + public description = "A test streams tool"; + public argsShape = { + projectId: z.string().describe("project id"), + workspaceName: z.string().optional().describe("workspace name"), + resourceName: z.string().optional().describe("resource name"), + action: z.string().optional().describe("action"), + }; + + protected execute(): Promise { + return Promise.resolve({ content: [{ type: "text", text: "ok" }] }); + } + + // Expose protected static methods for testing + public static testExtractConnectionNames(obj: unknown): Set { + return StreamsToolBase.extractConnectionNames(obj); + } + + // Expose protected methods for testing + public testHandleError( + error: unknown, + args: ToolArgs + ): Promise | CallToolResult { + return this.handleError(error, args); + } + + public testVerifyAllowed(): boolean { + return this.verifyAllowed(); + } + + public testResolveTelemetryMetadata( + args: ToolArgs, + result: CallToolResult + ): TelemetryToolMetadata { + return this.resolveTelemetryMetadata(args, { result }); + } +} + +function createApiClientError(status: number, message: string): ApiClientError { + const response = new Response(null, { status, statusText: "Error" }); + return ApiClientError.fromError(response, { reason: message, error: status, errorCode: `${status}` }); +} + +describe("StreamsToolBase", () => { + let mockSession: Session; + let mockConfig: UserConfig; + let mockTelemetry: Telemetry; + let mockElicitation: Elicitation; + let tool: TestStreamsTool; + + beforeEach(() => { + const mockLogger = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + } as unknown as CompositeLogger; + + mockSession = { + logger: mockLogger, + apiClient: {}, + } as unknown as Session; + + mockConfig = { + confirmationRequiredTools: [], + previewFeatures: ["streams"], + disabledTools: [], + apiClientId: "test-id", + apiClientSecret: "test-secret", + } as unknown as UserConfig; + + mockTelemetry = { + isTelemetryEnabled: () => true, + emitEvents: vi.fn(), + } as unknown as Telemetry; + + mockElicitation = { + requestConfirmation: vi.fn(), + } as unknown as Elicitation; + + const params: ToolConstructorParams = { + name: TestStreamsTool.toolName, + category: "atlas", + operationType: TestStreamsTool.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + metrics: new MockMetrics(), + uiRegistry: new UIRegistry(), + }; + + tool = new TestStreamsTool(params); + }); + + // Cast partial args since ToolArgs requires all keys even for optional Zod fields + const defaultArgs = { projectId: "proj1" } as never; + + describe("handleError", () => { + it("should handle 404 with discover hint", () => { + const error = createApiClientError(404, "Not found"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect(result.content[0]).toHaveProperty("text"); + expect((result.content[0] as { text: string }).text).toContain("Resource not found"); + expect((result.content[0] as { text: string }).text).toContain("atlas-streams-discover"); + }); + + it("should handle 403 with active processor in message", () => { + const error = createApiClientError(403, "Cannot delete workspace with active processor"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Stop all processors first"); + }); + + it("should handle 400 with topic + AtlasCollection ($emit not $merge hint)", () => { + const error = createApiClientError(400, "IDLUnknownField: 'topic' in AtlasCollection"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("$emit"); + expect((result.content[0] as { text: string }).text).toContain("not $merge"); + }); + + it("should handle 400 with schemaRegistryName hint", () => { + const error = createApiClientError(400, "IDLUnknownField: schemaRegistryName"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("schemaRegistry:"); + }); + + it("should handle 400 with valueSchema missing hint", () => { + const error = createApiClientError(400, "IDLFailedToParse: valueSchema is missing"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("valueSchema is required"); + }); + + it("should handle 400 with Enumeration type (case-sensitive hint)", () => { + const error = createApiClientError(400, "BadValue: Enumeration value 'AVRO' for type not valid"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("case-sensitive"); + }); + + it("should handle 400 with MergeOperatorSpec hint", () => { + const error = createApiClientError(400, "IDLUnknownField in MergeOperatorSpec"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("$merge writes to Atlas clusters"); + expect((result.content[0] as { text: string }).text).toContain("$emit instead"); + }); + + it("should handle 400 generic with default hint", () => { + const error = createApiClientError(400, "Some other bad request"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Bad Request"); + expect((result.content[0] as { text: string }).text).toContain("invalid configuration"); + }); + + it("should handle 409 conflict", () => { + const error = createApiClientError(409, "Resource already exists"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Conflict"); + expect((result.content[0] as { text: string }).text).toContain("atlas-streams-discover"); + }); + + it("should handle resumeFromCheckpoint error", () => { + const error = new Error("Failed due to resumeFromCheckpoint conflict"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Checkpoint conflict"); + expect((result.content[0] as { text: string }).text).toContain("resumeFromCheckpoint=false"); + }); + + it("should handle SASL authentication error", () => { + const error = new Error("SASL authentication failed"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Authentication failure"); + expect((result.content[0] as { text: string }).text).toContain("Kafka connection credentials"); + }); + + it("should handle authentication failed error", () => { + const error = new Error("Broker returned authentication failed"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Authentication failure"); + }); + + it("should handle INVALID_STATE error", () => { + const error = new Error("INVALID_STATE: cannot transition from STARTED to STARTED"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Invalid state transition"); + }); + + it("should delegate other errors to super.handleError()", () => { + const error = new Error("Something totally unexpected"); + const result = tool.testHandleError(error, defaultArgs) as CallToolResult; + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Something totally unexpected"); + // Should NOT contain any streams-specific hints + expect((result.content[0] as { text: string }).text).not.toContain("atlas-streams-discover"); + expect((result.content[0] as { text: string }).text).not.toContain("Checkpoint conflict"); + }); + }); + + describe("verifyAllowed", () => { + it("should return false when streams feature is not enabled", () => { + mockConfig.previewFeatures = []; + expect(tool.testVerifyAllowed()).toBe(false); + }); + + it("should return true when streams feature is enabled and API credentials are set", () => { + mockConfig.previewFeatures = ["streams"]; + expect(tool.testVerifyAllowed()).toBe(true); + }); + + it("should return false when streams is enabled but no API credentials", () => { + mockConfig.previewFeatures = ["streams"]; + mockConfig.apiClientId = ""; + mockConfig.apiClientSecret = ""; + expect(tool.testVerifyAllowed()).toBe(false); + }); + }); + + describe("resolveTelemetryMetadata", () => { + const okResult: CallToolResult = { content: [{ type: "text", text: "ok" }] }; + + it("should include action in metadata", () => { + const metadata = tool.testResolveTelemetryMetadata( + { projectId: "proj1", action: "list-workspaces" } as never, + okResult + ); + expect(metadata).toHaveProperty("action", "list-workspaces"); + }); + + it("should return base metadata on invalid args", () => { + // projectId is required, passing empty object should fail Zod parse + const metadata = tool.testResolveTelemetryMetadata({} as never, okResult); + expect(metadata).not.toHaveProperty("action"); + }); + }); + + describe("extractConnectionNames", () => { + const extract = (obj: unknown): Set => TestStreamsTool.testExtractConnectionNames(obj); + + it("should extract connectionName from flat objects", () => { + const result = extract({ connectionName: "src" }); + expect(result).toEqual(new Set(["src"])); + }); + + it("should extract connectionNames from pipeline arrays", () => { + const pipeline = [ + { $source: { connectionName: "src" } }, + { $merge: { into: { connectionName: "sink", db: "db1", coll: "c1" } } }, + ]; + const result = extract(pipeline); + expect(result).toEqual(new Set(["src", "sink"])); + }); + + it("should extract deeply nested connectionNames (e.g. schemaRegistry)", () => { + const pipeline = [ + { $source: { connectionName: "kafka-in" } }, + { + $emit: { + connectionName: "kafka-out", + schemaRegistry: { connectionName: "sr-conn" }, + }, + }, + ]; + const result = extract(pipeline); + expect(result).toEqual(new Set(["kafka-in", "kafka-out", "sr-conn"])); + }); + + it("should return empty set for primitives and null", () => { + expect(extract(null)).toEqual(new Set()); + expect(extract("string")).toEqual(new Set()); + expect(extract(42)).toEqual(new Set()); + expect(extract(undefined)).toEqual(new Set()); + }); + + it("should return empty set for empty pipeline", () => { + expect(extract([])).toEqual(new Set()); + }); + }); +}); diff --git a/tests/unit/tools/atlas/streams/teardown.test.ts b/tests/unit/tools/atlas/streams/teardown.test.ts new file mode 100644 index 000000000..fad516241 --- /dev/null +++ b/tests/unit/tools/atlas/streams/teardown.test.ts @@ -0,0 +1,464 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ToolConstructorParams } from "../../../../../src/tools/tool.js"; +import { StreamsTeardownTool } from "../../../../../src/tools/atlas/streams/teardown.js"; +import type { Session } from "../../../../../src/common/session.js"; +import type { UserConfig } from "../../../../../src/common/config/userConfig.js"; +import type { Telemetry } from "../../../../../src/telemetry/telemetry.js"; +import type { Elicitation } from "../../../../../src/elicitation.js"; +import type { CompositeLogger } from "../../../../../src/common/logging/index.js"; +import type { ApiClient } from "../../../../../src/common/atlas/apiClient.js"; +import { UIRegistry } from "../../../../../src/ui/registry/index.js"; +import { MockMetrics } from "../../../mocks/metrics.js"; + +describe("StreamsTeardownTool", () => { + let mockApiClient: Record>; + let tool: StreamsTeardownTool; + + beforeEach(() => { + mockApiClient = { + getStreamProcessor: vi.fn(), + stopStreamProcessor: vi.fn(), + deleteStreamProcessor: vi.fn(), + getStreamProcessors: vi.fn(), + deleteStreamConnection: vi.fn(), + listStreamConnections: vi.fn(), + deleteStreamWorkspace: vi.fn(), + deletePrivateLinkConnection: vi.fn(), + deleteVpcPeeringConnection: vi.fn(), + }; + + const mockLogger = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + } as unknown as CompositeLogger; + + const mockSession = { + logger: mockLogger, + apiClient: mockApiClient as unknown as ApiClient, + } as unknown as Session; + + const mockConfig = { + confirmationRequiredTools: [], + previewFeatures: ["streams"], + disabledTools: [], + apiClientId: "test-id", + apiClientSecret: "test-secret", + } as unknown as UserConfig; + + const mockTelemetry = { + isTelemetryEnabled: () => true, + emitEvents: vi.fn(), + } as unknown as Telemetry; + + const mockElicitation = { + requestConfirmation: vi.fn().mockResolvedValue(true), + } as unknown as Elicitation; + + const params: ToolConstructorParams = { + name: StreamsTeardownTool.toolName, + category: "atlas", + operationType: StreamsTeardownTool.operationType, + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + metrics: new MockMetrics(), + uiRegistry: new UIRegistry(), + }; + + tool = new StreamsTeardownTool(params); + }); + + const baseArgs = { projectId: "proj1" }; + // Helper to call execute/getConfirmationMessage with partial args (tests validate missing fields at runtime) + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const exec = (args: Record) => tool["execute"](args as never); + const confirmMsg = (args: Record): string => tool["getConfirmationMessage"](args as never); + + describe("deleteProcessor", () => { + it("should stop then delete a STARTED processor", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STARTED", name: "proc1" }); + mockApiClient.stopStreamProcessor!.mockResolvedValue({}); + mockApiClient.deleteStreamProcessor!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + expect(mockApiClient.stopStreamProcessor).toHaveBeenCalledOnce(); + expect(mockApiClient.deleteStreamProcessor).toHaveBeenCalledOnce(); + expect((result.content[0] as { text: string }).text).toContain("deleted"); + }); + + it("should delete a STOPPED processor without stopping first", async () => { + mockApiClient.getStreamProcessor!.mockResolvedValue({ state: "STOPPED", name: "proc1" }); + mockApiClient.deleteStreamProcessor!.mockResolvedValue({}); + + await exec({ + ...baseArgs, + resource: "processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + + expect(mockApiClient.stopStreamProcessor).not.toHaveBeenCalled(); + expect(mockApiClient.deleteStreamProcessor).toHaveBeenCalledOnce(); + }); + + it("should throw when workspaceName is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "processor", + resourceName: "proc1", + }) + ).rejects.toThrow("workspaceName is required"); + }); + + it("should throw when resourceName is missing", async () => { + await expect( + exec({ + ...baseArgs, + resource: "processor", + workspaceName: "ws1", + }) + ).rejects.toThrow("resourceName is required"); + }); + }); + + describe("deleteConnection", () => { + it("should delete connection when no processors reference it", async () => { + mockApiClient.getStreamProcessors!.mockResolvedValue({ results: [] }); + mockApiClient.deleteStreamConnection!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "connection", + workspaceName: "ws1", + resourceName: "conn1", + }); + + expect(mockApiClient.deleteStreamConnection).toHaveBeenCalledOnce(); + expect((result.content[0] as { text: string }).text).toContain("deletion initiated"); + }); + + it("should warn and NOT delete when running processor references connection", async () => { + mockApiClient.getStreamProcessors!.mockResolvedValue({ + results: [ + { + name: "proc1", + state: "STARTED", + pipeline: [{ $source: { connectionName: "conn1" } }], + }, + ], + }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + workspaceName: "ws1", + resourceName: "conn1", + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("Warning"); + expect((result.content[0] as { text: string }).text).toContain("running processor"); + expect(mockApiClient.deleteStreamConnection).not.toHaveBeenCalled(); + }); + + it("should detect deeply nested connection references (e.g. schemaRegistry)", async () => { + mockApiClient.getStreamProcessors!.mockResolvedValue({ + results: [ + { + name: "proc1", + state: "STARTED", + pipeline: [ + { $source: { connectionName: "kafka-in" } }, + { + $emit: { + connectionName: "kafka-out", + schemaRegistry: { connectionName: "conn1" }, + }, + }, + ], + }, + ], + }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + workspaceName: "ws1", + resourceName: "conn1", + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain("running processor"); + expect(mockApiClient.deleteStreamConnection).not.toHaveBeenCalled(); + }); + + it("should only name running processors in warning when mix of running and stopped reference connection", async () => { + mockApiClient.getStreamProcessors!.mockResolvedValue({ + results: [ + { + name: "running-proc", + state: "STARTED", + pipeline: [{ $source: { connectionName: "conn1" } }], + }, + { + name: "stopped-proc", + state: "STOPPED", + pipeline: [{ $emit: { connectionName: "conn1" } }], + }, + ], + }); + + const result = await exec({ + ...baseArgs, + resource: "connection", + workspaceName: "ws1", + resourceName: "conn1", + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("running-proc"); + expect(text).not.toContain("stopped-proc"); + expect(mockApiClient.deleteStreamConnection).not.toHaveBeenCalled(); + }); + + it("should proceed with deletion when only stopped processors reference connection", async () => { + mockApiClient.getStreamProcessors!.mockResolvedValue({ + results: [ + { + name: "proc1", + state: "STOPPED", + pipeline: [{ $source: { connectionName: "conn1" } }], + }, + ], + }); + mockApiClient.deleteStreamConnection!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "connection", + workspaceName: "ws1", + resourceName: "conn1", + }); + + expect(mockApiClient.deleteStreamConnection).toHaveBeenCalledOnce(); + expect((result.content[0] as { text: string }).text).toContain("deletion initiated"); + }); + + it("should proceed with deletion when processor list API fails", async () => { + mockApiClient.getStreamProcessors!.mockRejectedValue(new Error("API error")); + mockApiClient.deleteStreamConnection!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "connection", + workspaceName: "ws1", + resourceName: "conn1", + }); + + expect(mockApiClient.deleteStreamConnection).toHaveBeenCalledOnce(); + expect((result.content[0] as { text: string }).text).toContain("deletion initiated"); + }); + }); + + describe("deleteWorkspace", () => { + it("should include impact note when workspace has connections and processors", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "c1" }, { name: "c2" }, { name: "c3" }], + }); + mockApiClient.getStreamProcessors!.mockResolvedValue({ + results: [{ name: "p1" }, { name: "p2" }], + }); + mockApiClient.deleteStreamWorkspace!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "workspace", + workspaceName: "ws1", + }); + + expect((result.content[0] as { text: string }).text).toContain("2 processor(s)"); + expect((result.content[0] as { text: string }).text).toContain("3 connection(s)"); + }); + + it("should not include impact note for empty workspace", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ results: [] }); + mockApiClient.getStreamProcessors!.mockResolvedValue({ results: [] }); + mockApiClient.deleteStreamWorkspace!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "workspace", + workspaceName: "ws1", + }); + + expect((result.content[0] as { text: string }).text).not.toContain("processor(s)"); + expect((result.content[0] as { text: string }).text).toContain("deletion initiated"); + }); + + it("should proceed without impact note when count APIs fail", async () => { + mockApiClient.listStreamConnections!.mockRejectedValue(new Error("fail")); + mockApiClient.getStreamProcessors!.mockRejectedValue(new Error("fail")); + mockApiClient.deleteStreamWorkspace!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "workspace", + workspaceName: "ws1", + }); + + expect((result.content[0] as { text: string }).text).toContain("deletion initiated"); + }); + + it("should include connection count when only connection API succeeds", async () => { + mockApiClient.listStreamConnections!.mockResolvedValue({ + results: [{ name: "c1" }, { name: "c2" }], + }); + mockApiClient.getStreamProcessors!.mockRejectedValue(new Error("fail")); + mockApiClient.deleteStreamWorkspace!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "workspace", + workspaceName: "ws1", + }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("2 connection(s)"); + expect(text).toContain("0 processor(s)"); + }); + + it("should include processor count when only processor API succeeds", async () => { + mockApiClient.listStreamConnections!.mockRejectedValue(new Error("fail")); + mockApiClient.getStreamProcessors!.mockResolvedValue({ + results: [{ name: "p1" }, { name: "p2" }, { name: "p3" }], + }); + mockApiClient.deleteStreamWorkspace!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "workspace", + workspaceName: "ws1", + }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("3 processor(s)"); + expect(text).toContain("0 connection(s)"); + }); + }); + + describe("deletePrivateLink", () => { + it("should call correct API and return confirmation", async () => { + mockApiClient.deletePrivateLinkConnection!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "privatelink", + resourceName: "pl-123", + }); + + expect(mockApiClient.deletePrivateLinkConnection).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1", connectionId: "pl-123" } }, + }); + expect((result.content[0] as { text: string }).text).toContain("pl-123"); + expect((result.content[0] as { text: string }).text).toContain("deletion initiated"); + }); + }); + + describe("deletePeering", () => { + it("should call correct API and return confirmation", async () => { + mockApiClient.deleteVpcPeeringConnection!.mockResolvedValue({}); + + const result = await exec({ + ...baseArgs, + resource: "peering", + resourceName: "peer-456", + }); + + expect(mockApiClient.deleteVpcPeeringConnection).toHaveBeenCalledWith({ + params: { path: { groupId: "proj1", id: "peer-456" } }, + }); + expect((result.content[0] as { text: string }).text).toContain("peer-456"); + }); + }); + + describe("getConfirmationMessage", () => { + it("should return workspace deletion warning", () => { + const msg = confirmMsg({ + ...baseArgs, + resource: "workspace", + workspaceName: "ws1", + }); + expect(msg).toContain("delete workspace"); + expect(msg).toContain("ALL connections and processors"); + }); + + it("should return processor deletion warning", () => { + const msg = confirmMsg({ + ...baseArgs, + resource: "processor", + workspaceName: "ws1", + resourceName: "proc1", + }); + expect(msg).toContain("delete processor"); + expect(msg).toContain("checkpoints"); + }); + + it("should return connection deletion warning", () => { + const msg = confirmMsg({ + ...baseArgs, + resource: "connection", + workspaceName: "ws1", + resourceName: "conn1", + }); + expect(msg).toContain("delete connection"); + }); + + it("should return privatelink deletion warning", () => { + const msg = confirmMsg({ + ...baseArgs, + resource: "privatelink", + resourceName: "pl-1", + }); + expect(msg).toContain("PrivateLink"); + }); + + it("should return peering deletion warning", () => { + const msg = confirmMsg({ + ...baseArgs, + resource: "peering", + resourceName: "peer-1", + }); + expect(msg).toContain("VPC peering"); + }); + + it("should throw when workspaceName is missing for workspace/processor/connection", () => { + for (const resource of ["workspace", "processor", "connection"]) { + expect(() => confirmMsg({ ...baseArgs, resource, resourceName: "r1" })).toThrow( + "workspaceName is required" + ); + } + }); + + it("should throw when resourceName is missing for processor/connection/privatelink/peering", () => { + for (const resource of ["processor", "connection"]) { + expect(() => confirmMsg({ ...baseArgs, resource, workspaceName: "ws1" })).toThrow( + "resourceName is required" + ); + } + for (const resource of ["privatelink", "peering"]) { + expect(() => confirmMsg({ ...baseArgs, resource })).toThrow("resourceName is required"); + } + }); + }); +}); diff --git a/tests/utils/elicitationMocks.ts b/tests/utils/elicitationMocks.ts index f46643d4c..d044c14c7 100644 --- a/tests/utils/elicitationMocks.ts +++ b/tests/utils/elicitationMocks.ts @@ -11,9 +11,7 @@ export type MockClientCapabilities = { export type MockElicitResult = { action: string; - content?: { - confirmation?: string; - }; + content?: Record; }; /** @@ -23,7 +21,7 @@ export function createMockElicitInput(): { mock: MockedFunction<() => Promise>; confirmYes: () => void; confirmNo: () => void; - acceptWith: (content: { confirmation?: string } | undefined) => void; + acceptWith: (content: Record | undefined) => void; cancel: () => void; rejectWith: (error: Error) => void; clear: () => void; @@ -42,7 +40,7 @@ export function createMockElicitInput(): { action: "accept", content: { confirmation: "No" }, }), - acceptWith: (content: { confirmation?: string } | undefined) => + acceptWith: (content: Record | undefined) => mockFn.mockResolvedValue({ action: "accept", content, diff --git a/vitest.config.ts b/vitest.config.ts index 46b706bcb..22e5e6c38 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,8 +12,8 @@ const vitestDefaultExcludes = [ const longRunningTests = ["tests/integration/tools/atlas/performanceAdvisor.test.ts"]; -if (process.env.SKIP_ATLAS_TESTS === "true") { - vitestDefaultExcludes.push("**/atlas/**"); +if (process.env.SKIP_ATLAS_INTEGRATION_TESTS === "true") { + vitestDefaultExcludes.push("**/integration/**/atlas/**"); } if (process.env.SKIP_ATLAS_LOCAL_TESTS === "true") {