diff --git a/mocks/handlers.ts b/mocks/handlers.ts index dca11205..e6a12d0d 100644 --- a/mocks/handlers.ts +++ b/mocks/handlers.ts @@ -214,6 +214,77 @@ export const handlers = [ }); }), + // ============================================================ + // StackOne Actions RPC endpoint + // ============================================================ + http.post('https://api.stackone.com/actions/rpc', async ({ request }) => { + const authHeader = request.headers.get('Authorization'); + + // Check for authentication + if (!authHeader || !authHeader.startsWith('Basic ')) { + return HttpResponse.json( + { error: 'Unauthorized', message: 'Missing or invalid authorization header' }, + { status: 401 } + ); + } + + const body = (await request.json()) as { + action?: string; + body?: Record; + headers?: Record; + path?: Record; + query?: Record; + }; + + // Validate action is provided + if (!body.action) { + return HttpResponse.json( + { error: 'Bad Request', message: 'Action is required' }, + { status: 400 } + ); + } + + // Return mock response based on action + if (body.action === 'hris_get_employee') { + return HttpResponse.json({ + data: { + id: body.path?.id || 'test-id', + name: 'Test Employee', + ...(body.body || {}), + }, + }); + } + + if (body.action === 'hris_list_employees') { + return HttpResponse.json({ + data: [ + { id: '1', name: 'Employee 1' }, + { id: '2', name: 'Employee 2' }, + ], + }); + } + + if (body.action === 'test_error_action') { + return HttpResponse.json( + { error: 'Internal Server Error', message: 'Test error response' }, + { status: 500 } + ); + } + + // Default response for other actions + return HttpResponse.json({ + data: { + action: body.action, + received: { + body: body.body, + headers: body.headers, + path: body.path, + query: body.query, + }, + }, + }); + }), + // ============================================================ // StackOne Unified HRIS endpoints // ============================================================ diff --git a/package.json b/package.json index f2e28bfe..c3f2c27f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "catalog:prod", "@orama/orama": "catalog:prod", - "@stackone/stackone-client-ts": "catalog:prod", "json-schema": "catalog:prod" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 879d3f51..ae0fe891 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,9 +80,6 @@ catalogs: '@orama/orama': specifier: ^3.1.11 version: 3.1.16 - '@stackone/stackone-client-ts': - specifier: 4.32.2 - version: 4.32.2 json-schema: specifier: ^0.4.0 version: 0.4.0 @@ -97,9 +94,6 @@ importers: '@orama/orama': specifier: catalog:prod version: 3.1.16 - '@stackone/stackone-client-ts': - specifier: catalog:prod - version: 4.32.2 json-schema: specifier: catalog:prod version: 0.4.0 @@ -951,9 +945,6 @@ packages: cpu: [x64] os: [win32] - '@stackone/stackone-client-ts@4.32.2': - resolution: {integrity: sha512-fGQ5IO/6faTNw1RoDE/4YOfRhp0vnnK8zo05OsW8aIK3eZuCqNI1J73YlcmJ0PxAr4A7206caEXgBPCfnUDLCA==} - '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -2714,10 +2705,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true - '@stackone/stackone-client-ts@4.32.2': - dependencies: - zod: 3.25.76 - '@standard-schema/spec@1.0.0': {} '@tybys/wasm-util@0.10.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0959a8d2..621d3073 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,16 +32,12 @@ catalogs: prod: '@modelcontextprotocol/sdk': ^1.19.1 '@orama/orama': ^3.1.11 - '@stackone/stackone-client-ts': 4.32.2 json-schema: ^0.4.0 enablePrePostScripts: true minimumReleaseAge: 1440 -minimumReleaseAgeExclude: - - '@stackone/stackone-client-ts' - onlyBuiltDependencies: - '@biomejs/biome' - esbuild diff --git a/src/mcp.ts b/src/mcp-client.ts similarity index 100% rename from src/mcp.ts rename to src/mcp-client.ts diff --git a/src/rpc-client.spec.ts b/src/rpc-client.spec.ts new file mode 100644 index 00000000..cf69b698 --- /dev/null +++ b/src/rpc-client.spec.ts @@ -0,0 +1,104 @@ +import { RpcClient } from './rpc-client'; +import { StackOneAPIError } from './utils/errors'; + +test('should successfully execute an RPC action', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'hris_get_employee', + body: { fields: 'name,email' }, + path: { id: 'emp-123' }, + }); + + // Response matches server's ActionsRpcResponseApiModel structure + expect(response).toHaveProperty('data'); + expect(response.data).toMatchObject({ + id: 'emp-123', + name: 'Test Employee', + }); +}); + +test('should send correct payload structure', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'custom_action', + body: { key: 'value' }, + headers: { 'x-custom': 'header' }, + path: { id: '123' }, + query: { filter: 'active' }, + }); + + // Response matches server's ActionsRpcResponseApiModel structure + expect(response.data).toMatchObject({ + action: 'custom_action', + received: { + body: { key: 'value' }, + headers: { 'x-custom': 'header' }, + path: { id: '123' }, + query: { filter: 'active' }, + }, + }); +}); + +test('should handle list actions with array data', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'hris_list_employees', + }); + + // Response data can be an array (matches RpcActionResponseData union type) + expect(Array.isArray(response.data)).toBe(true); + expect(response.data).toMatchObject([ + { id: expect.any(String), name: expect.any(String) }, + { id: expect.any(String), name: expect.any(String) }, + ]); +}); + +test('should throw StackOneAPIError on server error', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + await expect( + client.actions.rpcAction({ + action: 'test_error_action', + }) + ).rejects.toThrow(StackOneAPIError); +}); + +test('should include request body in error for debugging', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + await expect( + client.actions.rpcAction({ + action: 'test_error_action', + body: { debug: 'data' }, + }) + ).rejects.toMatchObject({ + statusCode: 500, + requestBody: { action: 'test_error_action' }, + }); +}); + +test('should work with only action parameter', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'simple_action', + }); + + // Response has data field (server returns { data: { action, received } }) + expect(response).toHaveProperty('data'); +}); diff --git a/src/rpc-client.ts b/src/rpc-client.ts new file mode 100644 index 00000000..2c5370b9 --- /dev/null +++ b/src/rpc-client.ts @@ -0,0 +1,149 @@ +import { z } from 'zod'; +import { StackOneAPIError } from './utils/errors'; + +/** + * Zod schema for RPC action request validation + * @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action + */ +const rpcActionRequestSchema = z.object({ + action: z.string(), + body: z.record(z.unknown()).optional(), + headers: z.record(z.unknown()).optional(), + path: z.record(z.unknown()).optional(), + query: z.record(z.unknown()).optional(), +}); + +/** + * RPC action request payload + */ +export type RpcActionRequest = z.infer; + +/** + * Zod schema for RPC action response data + */ +const rpcActionResponseDataSchema = z.union([ + z.record(z.unknown()), + z.array(z.record(z.unknown())), + z.null(), +]); + +/** + * Zod schema for RPC action response validation + * + * The server returns a flexible JSON structure. Known fields: + * - `data`: The main response data (object, array, or null) + * - `next`: Pagination cursor for fetching next page + * + * Additional fields from the connector response are passed through. + * @see unified-cloud-api/src/unified-api-v2/unifiedAPIv2.service.ts processActionCall + */ +const rpcActionResponseSchema = z + .object({ + next: z.string().nullish(), + data: rpcActionResponseDataSchema.optional(), + }) + .passthrough(); + +/** + * RPC action response data type - can be object, array of objects, or null + */ +export type RpcActionResponseData = z.infer; + +/** + * RPC action response from the StackOne API + * Contains known fields (data, next) plus any additional fields from the connector + */ +export type RpcActionResponse = z.infer; + +/** + * Zod schema for RPC client configuration validation + */ +const rpcClientConfigSchema = z.object({ + serverURL: z.string().optional(), + security: z.object({ + username: z.string(), + password: z.string().optional(), + }), +}); + +/** + * Configuration for the RPC client + */ +export type RpcClientConfig = z.infer; + +/** + * Custom RPC client for StackOne API. + * Replaces the @stackone/stackone-client-ts dependency. + * + * @see https://docs.stackone.com/platform/api-reference/actions/list-all-actions-metadata + * @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action + */ +export class RpcClient { + private readonly baseUrl: string; + private readonly authHeader: string; + + constructor(config: RpcClientConfig) { + const validatedConfig = rpcClientConfigSchema.parse(config); + this.baseUrl = validatedConfig.serverURL || 'https://api.stackone.com'; + const username = validatedConfig.security.username; + const password = validatedConfig.security.password || ''; + this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + } + + /** + * Actions namespace containing RPC methods + */ + readonly actions = { + /** + * Execute an RPC action + * @param request The RPC action request + * @returns The RPC action response matching server's ActionsRpcResponseApiModel + */ + rpcAction: async (request: RpcActionRequest): Promise => { + const validatedRequest = rpcActionRequestSchema.parse(request); + const url = `${this.baseUrl}/actions/rpc`; + + const requestBody = { + action: validatedRequest.action, + body: validatedRequest.body, + headers: validatedRequest.headers, + path: validatedRequest.path, + query: validatedRequest.query, + } as const satisfies RpcActionRequest; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.authHeader, + 'User-Agent': 'stackone-ai-node', + }, + body: JSON.stringify(requestBody), + }); + + const responseBody: unknown = await response.json(); + + if (!response.ok) { + throw new StackOneAPIError( + `RPC action failed for ${url}`, + response.status, + responseBody, + requestBody + ); + } + + const validation = rpcActionResponseSchema.safeParse(responseBody); + + if (!validation.success) { + throw new StackOneAPIError( + `Invalid RPC action response for ${url}`, + response.status, + responseBody, + requestBody + ); + } + + return validation.data; + }, + }; +} diff --git a/src/toolsets/base.ts b/src/toolsets/base.ts index e89d29f0..b98dfcff 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets/base.ts @@ -1,6 +1,6 @@ -import { StackOne } from '@stackone/stackone-client-ts'; import type { Arrayable } from 'type-fest'; -import { createMCPClient } from '../mcp'; +import { createMCPClient } from '../mcp-client'; +import { type RpcActionResponse, RpcClient } from '../rpc-client'; import { BaseTool, Tools } from '../tool'; import type { ExecuteOptions, @@ -12,6 +12,22 @@ import type { import { toArray } from '../utils/array'; import { StackOneError } from '../utils/errors'; +/** + * Converts RpcActionResponse to JsonDict in a type-safe manner. + * RpcActionResponse uses z.passthrough() which preserves additional fields, + * making it structurally compatible with Record. + */ +function rpcResponseToJsonDict(response: RpcActionResponse): JsonDict { + // RpcActionResponse with passthrough() has the shape: + // { next?: string | null, data?: ..., [key: string]: unknown } + // We extract all properties into a plain object + const result: JsonDict = {}; + for (const [key, value] of Object.entries(response)) { + result[key] = value; + } + return result; +} + type ToolInputSchema = Awaited< ReturnType>['client']['listTools']> >['tools'][number]['inputSchema']; @@ -66,7 +82,7 @@ export interface BaseToolSetConfig { baseUrl?: string; authentication?: AuthenticationConfig; headers?: Record; - stackOneClient?: StackOne; + rpcClient?: RpcClient; } /** @@ -76,7 +92,7 @@ export abstract class ToolSet { protected baseUrl?: string; protected authentication?: AuthenticationConfig; protected headers: Record; - protected stackOneClient?: StackOne; + protected rpcClient?: RpcClient; /** * Initialise a toolset with optional configuration @@ -86,7 +102,7 @@ export abstract class ToolSet { this.baseUrl = config?.baseUrl; this.authentication = config?.authentication; this.headers = config?.headers || {}; - this.stackOneClient = config?.stackOneClient; + this.rpcClient = config?.rpcClient; // Set Authentication headers if provided if (this.authentication) { @@ -194,9 +210,9 @@ export abstract class ToolSet { return new Tools(tools); } - private getActionsClient(): StackOne { - if (this.stackOneClient) { - return this.stackOneClient; + private getActionsClient(): RpcClient { + if (this.rpcClient) { + return this.rpcClient; } const credentials = this.authentication?.credentials ?? {}; @@ -212,11 +228,11 @@ export abstract class ToolSet { if (!apiKey) { throw new ToolSetConfigError( - 'StackOne API key is required to create an actions client. Provide stackOneClient, configure authentication credentials, or set the STACKONE_API_KEY environment variable.' + 'StackOne API key is required to create an actions client. Provide rpcClient, configure authentication credentials, or set the STACKONE_API_KEY environment variable.' ); } - this.stackOneClient = new StackOne({ + this.rpcClient = new RpcClient({ serverURL: this.baseUrl, security: { username: apiKey, @@ -224,7 +240,7 @@ export abstract class ToolSet { }, }); - return this.stackOneClient; + return this.rpcClient; } private createRpcBackedTool({ @@ -233,7 +249,7 @@ export abstract class ToolSet { description, inputSchema, }: { - actionsClient: StackOne; + actionsClient: RpcClient; name: string; description?: string; inputSchema: ToolInputSchema; @@ -332,7 +348,7 @@ export abstract class ToolSet { query: queryParams ?? undefined, }); - return (response.actionsRpcResponse ?? {}) as JsonDict; + return rpcResponseToJsonDict(response); } catch (error) { if (error instanceof StackOneError) { throw error;