diff --git a/packages/mcp-connectors/src/__mocks__/context.ts b/packages/mcp-connectors/src/__mocks__/context.ts index e2f22437..c70dfe76 100644 --- a/packages/mcp-connectors/src/__mocks__/context.ts +++ b/packages/mcp-connectors/src/__mocks__/context.ts @@ -1,10 +1,13 @@ import type { ConnectorContext } from '@stackone/mcp-config-types'; import { vi } from 'vitest'; -export function createMockConnectorContext(): ConnectorContext { +export function createMockConnectorContext(options?: { + credentials?: Record; + setup?: Record; +}): ConnectorContext { return { - getCredentials: vi.fn().mockResolvedValue({}), - getSetup: vi.fn().mockResolvedValue({}), + getCredentials: vi.fn().mockResolvedValue(options?.credentials || {}), + getSetup: vi.fn().mockResolvedValue(options?.setup || {}), getData: vi.fn().mockResolvedValue(undefined), setData: vi.fn().mockResolvedValue(undefined), readCache: vi.fn().mockResolvedValue(null), diff --git a/packages/mcp-connectors/src/connectors/autumn.spec.ts b/packages/mcp-connectors/src/connectors/autumn.spec.ts new file mode 100644 index 00000000..95719014 --- /dev/null +++ b/packages/mcp-connectors/src/connectors/autumn.spec.ts @@ -0,0 +1,345 @@ +import type { MCPToolDefinition } from '@stackone/mcp-config-types'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { createMockConnectorContext } from '../__mocks__/context'; +import { AutumnConnectorConfig } from './autumn'; + +const mockBaseUrl = 'https://api.useautumn.com/v1'; + +const server = setupServer( + http.get(`${mockBaseUrl}/customers/test-customer`, () => { + return HttpResponse.json({ + autumn_id: 'cus_2w5dzidzFD1cESxOGnn9frVuVcm', + created_at: 1677649423000, + env: 'production', + id: 'test-customer', + name: 'John Doe', + email: 'john@example.com', + stripe_id: 'cus_abc123', + products: [ + { + id: 'pro', + name: 'Pro Plan', + status: 'active', + }, + ], + features: [ + { + feature_id: 'api_calls', + unlimited: false, + balance: 1000, + usage: 100, + }, + ], + }); + }), + + http.get(`${mockBaseUrl}/customers/not-found`, () => { + return HttpResponse.json({ error: 'Customer not found' }, { status: 404 }); + }), + + http.post(`${mockBaseUrl}/customers`, async ({ request }) => { + const body = (await request.json()) as { id: string; name: string; email: string }; + return HttpResponse.json({ + autumn_id: 'cus_new123', + created_at: Date.now(), + env: 'production', + id: body.id, + name: body.name, + email: body.email, + products: [], + features: [], + }); + }), + + http.post(`${mockBaseUrl}/attach`, async ({ request }) => { + const body = (await request.json()) as { product_id: string }; + if (body.product_id === 'requires-payment') { + return HttpResponse.json({ + success: true, + url: 'https://checkout.stripe.com/pay/cs_test_123', + }); + } + return HttpResponse.json({ + success: true, + message: 'Product attached successfully', + }); + }), + + http.post(`${mockBaseUrl}/check`, async ({ request }) => { + const body = (await request.json()) as { feature_id: string }; + if (body.feature_id === 'premium_feature') { + return HttpResponse.json({ + access: false, + feature_id: 'premium_feature', + balance: 0, + usage: 0, + unlimited: false, + }); + } + return HttpResponse.json({ + access: true, + feature_id: body.feature_id, + balance: 900, + usage: 100, + unlimited: false, + }); + }), + + http.post(`${mockBaseUrl}/track`, async ({ request }) => { + await request.json(); + return HttpResponse.json({ + success: true, + message: 'Usage tracked successfully', + new_balance: 890, + new_usage: 110, + }); + }), + + http.get(`${mockBaseUrl}/products`, () => { + return HttpResponse.json([ + { + id: 'basic', + name: 'Basic Plan', + price: 9.99, + }, + { + id: 'pro', + name: 'Pro Plan', + price: 29.99, + }, + ]); + }), + + http.post(`${mockBaseUrl}/billing-portal`, async ({ request }) => { + await request.json(); + return HttpResponse.json({ + url: 'https://billing.stripe.com/session/abc123', + }); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('#AutumnConnector', () => { + describe('.GET_CUSTOMER', () => { + describe('when customer exists', () => { + it('returns customer information', async () => { + const tool = AutumnConnectorConfig.tools.GET_CUSTOMER as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler({ customer_id: 'test-customer' }, mockContext); + + const response = JSON.parse(actual); + expect(response.id).toBe('test-customer'); + expect(response.name).toBe('John Doe'); + expect(response.email).toBe('john@example.com'); + expect(response.products).toHaveLength(1); + expect(response.features).toHaveLength(1); + }); + }); + + describe('when customer does not exist', () => { + it('throws an error', async () => { + const tool = AutumnConnectorConfig.tools.GET_CUSTOMER as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + await expect( + tool.handler({ customer_id: 'not-found' }, mockContext) + ).rejects.toThrow('Autumn API error: 404 Not Found'); + }); + }); + }); + + describe('.CREATE_CUSTOMER', () => { + describe('when valid customer data is provided', () => { + it('creates and returns new customer', async () => { + const tool = AutumnConnectorConfig.tools.CREATE_CUSTOMER as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler( + { + customer_id: 'new-customer', + name: 'Jane Smith', + email: 'jane@example.com', + }, + mockContext + ); + + expect(actual).toContain('Customer created successfully'); + expect(actual).toContain('new-customer'); + expect(actual).toContain('Jane Smith'); + }); + }); + }); + + describe('.ATTACH_PRODUCT', () => { + describe('when product requires payment', () => { + it('returns checkout URL', async () => { + const tool = AutumnConnectorConfig.tools.ATTACH_PRODUCT as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler( + { + customer_id: 'test-customer', + product_id: 'requires-payment', + }, + mockContext + ); + + expect(actual).toContain('Payment URL generated'); + expect(actual).toContain('https://checkout.stripe.com'); + }); + }); + + describe('when product can be attached directly', () => { + it('attaches product successfully', async () => { + const tool = AutumnConnectorConfig.tools.ATTACH_PRODUCT as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler( + { + customer_id: 'test-customer', + product_id: 'free-product', + }, + mockContext + ); + + expect(actual).toContain('Product attached successfully'); + }); + }); + }); + + describe('.CHECK_ACCESS', () => { + describe('when customer has access to feature', () => { + it('returns access granted with usage info', async () => { + const tool = AutumnConnectorConfig.tools.CHECK_ACCESS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler( + { + customer_id: 'test-customer', + feature_id: 'api_calls', + }, + mockContext + ); + + expect(actual).toContain('Access: Granted'); + expect(actual).toContain('Balance: 900'); + expect(actual).toContain('Usage: 100'); + }); + }); + + describe('when customer does not have access to feature', () => { + it('returns access denied', async () => { + const tool = AutumnConnectorConfig.tools.CHECK_ACCESS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler( + { + customer_id: 'test-customer', + feature_id: 'premium_feature', + }, + mockContext + ); + + expect(actual).toContain('Access: Denied'); + expect(actual).toContain('Balance: 0'); + }); + }); + }); + + describe('.TRACK_USAGE', () => { + describe('when tracking usage for a feature', () => { + it('successfully tracks usage and returns updated balances', async () => { + const tool = AutumnConnectorConfig.tools.TRACK_USAGE as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler( + { + customer_id: 'test-customer', + feature_id: 'api_calls', + value: 10, + }, + mockContext + ); + + expect(actual).toContain('Usage tracked successfully'); + expect(actual).toContain('Feature: api_calls'); + expect(actual).toContain('Value: 10'); + expect(actual).toContain('New Balance: 890'); + expect(actual).toContain('New Usage: 110'); + }); + }); + }); + + describe('.LIST_PRODUCTS', () => { + describe('when requesting product list', () => { + it('returns all available products', async () => { + const tool = AutumnConnectorConfig.tools.LIST_PRODUCTS as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler({}, mockContext); + + const products = JSON.parse(actual); + expect(Array.isArray(products)).toBe(true); + expect(products).toHaveLength(2); + expect(products[0].id).toBe('basic'); + expect(products[1].id).toBe('pro'); + }); + }); + }); + + describe('.GET_BILLING_PORTAL', () => { + describe('when generating billing portal URL', () => { + it('returns portal URL', async () => { + const tool = AutumnConnectorConfig.tools.GET_BILLING_PORTAL as MCPToolDefinition; + const mockContext = createMockConnectorContext({ + credentials: { apiKey: 'test-api-key' }, + setup: { baseUrl: mockBaseUrl }, + }); + + const actual = await tool.handler( + { + customer_id: 'test-customer', + return_url: 'https://example.com/dashboard', + }, + mockContext + ); + + expect(actual).toContain('Billing portal URL:'); + expect(actual).toContain('https://billing.stripe.com'); + }); + }); + }); +}); diff --git a/packages/mcp-connectors/src/connectors/autumn.ts b/packages/mcp-connectors/src/connectors/autumn.ts new file mode 100644 index 00000000..0933b945 --- /dev/null +++ b/packages/mcp-connectors/src/connectors/autumn.ts @@ -0,0 +1,363 @@ +import { mcpConnectorConfig } from '@stackone/mcp-config-types'; +import { z } from 'zod'; + +interface AutumnCustomer { + autumn_id: string; + created_at: number; + env: string; + id: string; + name: string; + email: string; + stripe_id?: string; + products: Array<{ + id: string; + name: string; + status: string; + }>; + features: Array<{ + feature_id: string; + unlimited: boolean; + balance: number; + usage: number; + }>; +} + +interface AutumnAttachResponse { + url?: string; + success: boolean; + message?: string; +} + +interface AutumnCheckResponse { + access: boolean; + feature_id: string; + balance?: number; + usage?: number; + unlimited?: boolean; +} + +interface AutumnTrackResponse { + success: boolean; + message?: string; + new_balance?: number; + new_usage?: number; +} + +export const AutumnConnectorConfig = mcpConnectorConfig({ + name: 'Autumn', + key: 'autumn', + version: '1.0.0', + logo: 'https://stackone-logos.com/api/autumn/filled/svg', + description: + 'Open-source payment and billing platform for SaaS startups. Handles Stripe integration, usage tracking, feature access control, and subscription management.', + examplePrompt: + 'Get customer details for user "user_123", check their access to the "api_calls" feature, and track usage of 10 API calls.', + credentials: z.object({ + apiKey: z + .string() + .describe( + 'Your Autumn Secret API key from the dashboard :: autumn_sk_1234567890abcdef :: https://useautumn.com/dashboard/api-keys' + ), + }), + setup: z.object({ + baseUrl: z + .string() + .optional() + .describe('Autumn API base URL (defaults to https://api.useautumn.com/v1)'), + }), + tools: (tool) => ({ + GET_CUSTOMER: tool({ + name: 'autumn_get_customer', + description: + 'Retrieve detailed information about a specific customer including their subscriptions, add-ons, and entitlements', + schema: z.object({ + customer_id: z.string().describe('Your unique identifier for the customer'), + }), + handler: async (args, context) => { + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/customers/${args.customer_id}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const customer = (await response.json()) as AutumnCustomer; + return JSON.stringify(customer, null, 2); + }, + }), + CREATE_CUSTOMER: tool({ + name: 'autumn_create_customer', + description: 'Create a new customer in Autumn', + schema: z.object({ + customer_id: z.string().describe('Your unique identifier for the customer'), + name: z.string().describe('Customer name'), + email: z.string().email().describe('Customer email address'), + }), + handler: async (args, context) => { + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/customers`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + id: args.customer_id, + name: args.name, + email: args.email, + }), + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const customer = (await response.json()) as AutumnCustomer; + return `Customer created successfully:\n${JSON.stringify(customer, null, 2)}`; + }, + }), + ATTACH_PRODUCT: tool({ + name: 'autumn_attach_product', + description: + 'Attach a product to a customer, handling purchase flows and upgrades/downgrades', + schema: z.object({ + customer_id: z.string().describe('Your unique identifier for the customer'), + product_id: z.string().describe('The product ID to attach'), + success_url: z + .string() + .url() + .optional() + .describe('URL to redirect after successful payment'), + cancel_url: z + .string() + .url() + .optional() + .describe('URL to redirect after cancelled payment'), + }), + handler: async (args, context) => { + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/attach`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: args.customer_id, + product_id: args.product_id, + success_url: args.success_url, + cancel_url: args.cancel_url, + }), + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as AutumnAttachResponse; + return result.url + ? `Payment URL generated: ${result.url}` + : `Product attached successfully: ${result.message || 'Product enabled for customer'}`; + }, + }), + CHECK_ACCESS: tool({ + name: 'autumn_check_access', + description: + 'Check whether a customer has access to a specific feature and get usage information', + schema: z.object({ + customer_id: z.string().describe('Your unique identifier for the customer'), + feature_id: z.string().describe('The feature ID to check access for'), + }), + handler: async (args, context) => { + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/check`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: args.customer_id, + feature_id: args.feature_id, + }), + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as AutumnCheckResponse; + return `Feature Access Check: +- Feature: ${result.feature_id} +- Access: ${result.access ? 'Granted' : 'Denied'} +- Unlimited: ${result.unlimited || false} +- Balance: ${result.balance || 0} +- Usage: ${result.usage || 0}`; + }, + }), + TRACK_USAGE: tool({ + name: 'autumn_track_usage', + description: + 'Track a usage event when a customer uses a feature (e.g., uses a credit, API call)', + schema: z.object({ + customer_id: z.string().describe('Your unique identifier for the customer'), + feature_id: z.string().describe('The feature ID being used'), + value: z + .number() + .describe('The amount to track (e.g., 1 for one API call, 10 for 10 credits)'), + }), + handler: async (args, context) => { + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/track`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: args.customer_id, + feature_id: args.feature_id, + value: args.value, + }), + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as AutumnTrackResponse; + return `Usage tracked successfully: +- Feature: ${args.feature_id} +- Value: ${args.value} +- New Balance: ${result.new_balance || 'N/A'} +- New Usage: ${result.new_usage || 'N/A'} +${result.message ? `- Message: ${result.message}` : ''}`; + }, + }), + LIST_PRODUCTS: tool({ + name: 'autumn_list_products', + description: 'List all available products in your Autumn account', + schema: z.object({}), + handler: async (_args, context) => { + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/products`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const products = await response.json(); + return JSON.stringify(products, null, 2); + }, + }), + GET_BILLING_PORTAL: tool({ + name: 'autumn_get_billing_portal', + description: + 'Generate a billing portal URL for a customer to manage their subscription', + schema: z.object({ + customer_id: z.string().describe('Your unique identifier for the customer'), + return_url: z + .string() + .url() + .optional() + .describe('URL to redirect after billing portal session'), + }), + handler: async (args, context) => { + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/billing-portal`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: args.customer_id, + return_url: args.return_url, + }), + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + return `Billing portal URL: ${result.url}`; + }, + }), + }), + resources: (resource) => ({ + CUSTOMER_INFO: resource({ + name: 'customer_info', + uri: 'autumn://customer/{customer_id}', + title: 'Customer Information', + description: 'Get customer information and subscription status', + handler: async (uri, context) => { + const customerId = uri.split('/').pop(); + if (!customerId) { + throw new Error('Customer ID is required'); + } + + const { apiKey } = await context.getCredentials(); + const { baseUrl } = await context.getSetup(); + const apiBaseUrl = baseUrl || 'https://api.useautumn.com/v1'; + + const response = await fetch(`${apiBaseUrl}/customers/${customerId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Autumn API error: ${response.status} ${response.statusText}`); + } + + const customer = (await response.json()) as AutumnCustomer; + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(customer, null, 2), + }, + ], + }; + }, + }), + }), +}); diff --git a/packages/mcp-connectors/src/index.ts b/packages/mcp-connectors/src/index.ts index b1df72b5..727ab6de 100644 --- a/packages/mcp-connectors/src/index.ts +++ b/packages/mcp-connectors/src/index.ts @@ -3,6 +3,7 @@ import type { MCPConnectorConfig } from '@stackone/mcp-config-types'; // Import all connectors for the array import { AsanaConnectorConfig } from './connectors/asana'; import { AttioConnectorConfig } from './connectors/attio'; +import { AutumnConnectorConfig } from './connectors/autumn'; import { AwsConnectorConfig } from './connectors/aws'; import { DatadogConnectorConfig } from './connectors/datadog'; import { DeelConnectorConfig } from './connectors/deel'; @@ -45,6 +46,7 @@ export const Connectors: readonly MCPConnectorConfig[] = [ StackOneConnectorConfig, AsanaConnectorConfig, AttioConnectorConfig, + AutumnConnectorConfig, AwsConnectorConfig, DatadogConnectorConfig, DeelConnectorConfig, @@ -86,6 +88,7 @@ export { StackOneConnectorConfig, AsanaConnectorConfig, AttioConnectorConfig, + AutumnConnectorConfig, AwsConnectorConfig, DatadogConnectorConfig, DeelConnectorConfig,