From f40725be4e9f223c7a63c324411ea0da67bf37fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Dec 2025 10:01:22 +0000 Subject: [PATCH 01/15] feat!: replace @stackone/stackone-client-ts with custom RPC client Remove the external TypeScript client SDK dependency and implement a custom RPC client for the StackOne API. This reduces dependencies and gives full control over the API client implementation. Changes: - Add src/client/rpc-client.ts with RpcClient class - Update toolsets/base.ts to use new RpcClient instead of StackOne - Update tests to mock RpcClient instead of StackOne - Add comprehensive tests for the new RPC client using MSW - Add MSW handler for /actions/rpc endpoint - Remove @stackone/stackone-client-ts from dependencies BREAKING CHANGE: BaseToolSetConfig.stackOneClient is now rpcClient --- mocks/handlers.ts | 71 +++++++++ package.json | 1 - pnpm-lock.yaml | 13 -- pnpm-workspace.yaml | 4 - src/client/index.ts | 6 + src/client/rpc-client.ts | 94 ++++++++++++ src/client/tests/rpc-client.spec.ts | 137 ++++++++++++++++++ src/toolsets/base.ts | 22 +-- src/toolsets/tests/stackone.mcp-fetch.spec.ts | 70 ++++----- 9 files changed, 354 insertions(+), 64 deletions(-) create mode 100644 src/client/index.ts create mode 100644 src/client/rpc-client.ts create mode 100644 src/client/tests/rpc-client.spec.ts 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/client/index.ts b/src/client/index.ts new file mode 100644 index 00000000..850c23f5 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,6 @@ +export { + RpcClient, + type RpcActionRequest, + type RpcActionResponse, + type RpcClientConfig, +} from './rpc-client'; diff --git a/src/client/rpc-client.ts b/src/client/rpc-client.ts new file mode 100644 index 00000000..2d42eb9c --- /dev/null +++ b/src/client/rpc-client.ts @@ -0,0 +1,94 @@ +import type { JsonDict } from '../types'; +import { StackOneAPIError } from '../utils/errors'; + +/** + * RPC action request payload + */ +export interface RpcActionRequest { + action: string; + body?: JsonDict; + headers?: Record; + path?: JsonDict; + query?: JsonDict; +} + +/** + * RPC action response from the StackOne API + */ +export interface RpcActionResponse { + actionsRpcResponse?: JsonDict; +} + +/** + * Configuration for the RPC client + */ +export interface RpcClientConfig { + serverURL?: string; + security: { + username: string; + password?: string; + }; +} + +/** + * Custom RPC client for StackOne API + * Replaces the @stackone/stackone-client-ts dependency + */ +export class RpcClient { + private readonly baseUrl: string; + private readonly authHeader: string; + + constructor(config: RpcClientConfig) { + this.baseUrl = config.serverURL || 'https://api.stackone.com'; + const username = config.security.username; + const password = config.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 + */ + rpcAction: async (request: RpcActionRequest): Promise => { + const url = `${this.baseUrl}/actions/rpc`; + + const requestBody = { + action: request.action, + body: request.body, + headers: request.headers, + path: request.path, + query: request.query, + }; + + 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().catch(() => ({})); + + if (!response.ok) { + throw new StackOneAPIError( + `RPC action failed for ${url}`, + response.status, + responseBody, + requestBody + ); + } + + return { + actionsRpcResponse: responseBody as JsonDict, + }; + }, + }; +} diff --git a/src/client/tests/rpc-client.spec.ts b/src/client/tests/rpc-client.spec.ts new file mode 100644 index 00000000..5f3b667a --- /dev/null +++ b/src/client/tests/rpc-client.spec.ts @@ -0,0 +1,137 @@ +import { StackOneAPIError } from '../../utils/errors'; +import { RpcClient } from '../rpc-client'; + +describe('RpcClient', () => { + describe('constructor', () => { + it('should create client with default base URL', () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + expect(client).toBeInstanceOf(RpcClient); + }); + + it('should create client with custom server URL', () => { + const client = new RpcClient({ + serverURL: 'https://custom.api.stackone.com', + security: { username: 'test-api-key' }, + }); + expect(client).toBeInstanceOf(RpcClient); + }); + + it('should create client with username and password', () => { + const client = new RpcClient({ + security: { username: 'test-api-key', password: 'test-password' }, + }); + expect(client).toBeInstanceOf(RpcClient); + }); + }); + + describe('actions.rpcAction', () => { + it('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' }, + }); + + expect(response.actionsRpcResponse).toBeDefined(); + expect(response.actionsRpcResponse).toHaveProperty('data'); + }); + + it('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' }, + }); + + expect(response.actionsRpcResponse).toBeDefined(); + const data = response.actionsRpcResponse as { + data: { + action: string; + received: { + body: Record; + headers: Record; + path: Record; + query: Record; + }; + }; + }; + expect(data.data.action).toBe('custom_action'); + expect(data.data.received.body).toEqual({ key: 'value' }); + expect(data.data.received.headers).toEqual({ 'x-custom': 'header' }); + expect(data.data.received.path).toEqual({ id: '123' }); + expect(data.data.received.query).toEqual({ filter: 'active' }); + }); + + it('should handle list actions', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'hris_list_employees', + }); + + expect(response.actionsRpcResponse).toBeDefined(); + const data = response.actionsRpcResponse as { data: Array<{ id: string; name: string }> }; + expect(data.data).toHaveLength(2); + expect(data.data[0]).toHaveProperty('id'); + expect(data.data[0]).toHaveProperty('name'); + }); + + it('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); + }); + + it('should include request body in error for debugging', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + try { + await client.actions.rpcAction({ + action: 'test_error_action', + body: { debug: 'data' }, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(StackOneAPIError); + const apiError = error as StackOneAPIError; + expect(apiError.statusCode).toBe(500); + expect(apiError.requestBody).toBeDefined(); + expect(apiError.requestBody).toHaveProperty('action', 'test_error_action'); + } + }); + + it('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', + }); + + expect(response.actionsRpcResponse).toBeDefined(); + }); + }); +}); diff --git a/src/toolsets/base.ts b/src/toolsets/base.ts index e89d29f0..c70ad067 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets/base.ts @@ -1,5 +1,5 @@ -import { StackOne } from '@stackone/stackone-client-ts'; import type { Arrayable } from 'type-fest'; +import { RpcClient } from '../client'; import { createMCPClient } from '../mcp'; import { BaseTool, Tools } from '../tool'; import type { @@ -66,7 +66,7 @@ export interface BaseToolSetConfig { baseUrl?: string; authentication?: AuthenticationConfig; headers?: Record; - stackOneClient?: StackOne; + rpcClient?: RpcClient; } /** @@ -76,7 +76,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 +86,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 +194,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 +212,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 +224,7 @@ export abstract class ToolSet { }, }); - return this.stackOneClient; + return this.rpcClient; } private createRpcBackedTool({ @@ -233,7 +233,7 @@ export abstract class ToolSet { description, inputSchema, }: { - actionsClient: StackOne; + actionsClient: RpcClient; name: string; description?: string; inputSchema: ToolInputSchema; diff --git a/src/toolsets/tests/stackone.mcp-fetch.spec.ts b/src/toolsets/tests/stackone.mcp-fetch.spec.ts index 05b9cf9d..44dc0dd0 100644 --- a/src/toolsets/tests/stackone.mcp-fetch.spec.ts +++ b/src/toolsets/tests/stackone.mcp-fetch.spec.ts @@ -1,10 +1,10 @@ import { StreamableHTTPTransport } from '@hono/mcp'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { StackOne } from '@stackone/stackone-client-ts'; import { Hono } from 'hono'; import { assert, vi } from 'vitest'; import { z } from 'zod'; import { server as mswServer } from '../../../mocks/node'; +import type { RpcClient } from '../../client'; import { ToolSet } from '../base'; import { StackOneToolSet } from '../stackone'; @@ -105,18 +105,18 @@ describe('ToolSet.fetchTools (MCP + RPC integration)', () => { }); it('creates tools from MCP catalog and wires RPC execution', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; class TestToolSet extends ToolSet {} const toolset = new TestToolSet({ baseUrl: origin, headers: { 'x-account-id': 'test-account' }, - stackOneClient, + rpcClient, }); const tools = await toolset.fetchTools(); @@ -141,7 +141,7 @@ describe('ToolSet.fetchTools (MCP + RPC integration)', () => { { toolCallId: 'test-id', messages: [] } ); - expect(stackOneClient.actions.rpcAction).toHaveBeenCalledWith({ + expect(rpcClient.actions.rpcAction).toHaveBeenCalledWith({ action: 'dummy_action', body: { foo: 'bar' }, headers: { 'x-account-id': 'test-account' }, @@ -224,16 +224,16 @@ describe('StackOneToolSet account filtering', () => { }); it('supports setAccounts() for chaining', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Test chaining @@ -242,16 +242,16 @@ describe('StackOneToolSet account filtering', () => { }); it('fetches tools without account filtering when no accountIds provided', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); const tools = await toolset.fetchTools(); @@ -264,16 +264,16 @@ describe('StackOneToolSet account filtering', () => { }); it('uses x-account-id header when fetching tools with accountIds', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Fetch tools for acc1 @@ -287,16 +287,16 @@ describe('StackOneToolSet account filtering', () => { }); it('uses setAccounts when no accountIds provided in fetchTools', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Set accounts using setAccounts @@ -317,16 +317,16 @@ describe('StackOneToolSet account filtering', () => { }); it('overrides setAccounts when accountIds provided in fetchTools', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Set accounts using setAccounts @@ -393,16 +393,16 @@ describe('StackOneToolSet provider and action filtering', () => { }); it('filters tools by providers', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Filter by providers @@ -420,16 +420,16 @@ describe('StackOneToolSet provider and action filtering', () => { }); it('filters tools by actions with exact match', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Filter by exact action names @@ -446,16 +446,16 @@ describe('StackOneToolSet provider and action filtering', () => { }); it('filters tools by actions with glob pattern', async () => { - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Filter by glob pattern @@ -504,16 +504,16 @@ describe('StackOneToolSet provider and action filtering', () => { acc2: acc2Tools, }); - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: server.origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Combine account and action filters @@ -557,16 +557,16 @@ describe('StackOneToolSet provider and action filtering', () => { acc1: acc1Tools, }); - const stackOneClient = { + const rpcClient = { actions: { rpcAction: vi.fn(async () => ({ actionsRpcResponse: { data: null } })), }, - } as unknown as StackOne; + } as unknown as RpcClient; const toolset = new StackOneToolSet({ baseUrl: server.origin, apiKey: 'test-key', - stackOneClient, + rpcClient, }); // Combine all filters From 183425166a5ede4949d1150538eb4271f5769a16 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:13:29 +0000 Subject: [PATCH 02/15] refactor(client): flatten directory structure - Merge rpc-client.ts implementation into client/index.ts - Rename tests/rpc-client.spec.ts to index.spec.ts - Remove empty client/tests/ directory - Update import paths in test file --- .../rpc-client.spec.ts => index.spec.ts} | 4 +- src/client/index.ts | 100 ++++++++++++++++-- src/client/rpc-client.ts | 94 ---------------- 3 files changed, 96 insertions(+), 102 deletions(-) rename src/client/{tests/rpc-client.spec.ts => index.spec.ts} (97%) delete mode 100644 src/client/rpc-client.ts diff --git a/src/client/tests/rpc-client.spec.ts b/src/client/index.spec.ts similarity index 97% rename from src/client/tests/rpc-client.spec.ts rename to src/client/index.spec.ts index 5f3b667a..0455fd6e 100644 --- a/src/client/tests/rpc-client.spec.ts +++ b/src/client/index.spec.ts @@ -1,5 +1,5 @@ -import { StackOneAPIError } from '../../utils/errors'; -import { RpcClient } from '../rpc-client'; +import { StackOneAPIError } from '../utils/errors'; +import { RpcClient } from './index'; describe('RpcClient', () => { describe('constructor', () => { diff --git a/src/client/index.ts b/src/client/index.ts index 850c23f5..2d42eb9c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,94 @@ -export { - RpcClient, - type RpcActionRequest, - type RpcActionResponse, - type RpcClientConfig, -} from './rpc-client'; +import type { JsonDict } from '../types'; +import { StackOneAPIError } from '../utils/errors'; + +/** + * RPC action request payload + */ +export interface RpcActionRequest { + action: string; + body?: JsonDict; + headers?: Record; + path?: JsonDict; + query?: JsonDict; +} + +/** + * RPC action response from the StackOne API + */ +export interface RpcActionResponse { + actionsRpcResponse?: JsonDict; +} + +/** + * Configuration for the RPC client + */ +export interface RpcClientConfig { + serverURL?: string; + security: { + username: string; + password?: string; + }; +} + +/** + * Custom RPC client for StackOne API + * Replaces the @stackone/stackone-client-ts dependency + */ +export class RpcClient { + private readonly baseUrl: string; + private readonly authHeader: string; + + constructor(config: RpcClientConfig) { + this.baseUrl = config.serverURL || 'https://api.stackone.com'; + const username = config.security.username; + const password = config.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 + */ + rpcAction: async (request: RpcActionRequest): Promise => { + const url = `${this.baseUrl}/actions/rpc`; + + const requestBody = { + action: request.action, + body: request.body, + headers: request.headers, + path: request.path, + query: request.query, + }; + + 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().catch(() => ({})); + + if (!response.ok) { + throw new StackOneAPIError( + `RPC action failed for ${url}`, + response.status, + responseBody, + requestBody + ); + } + + return { + actionsRpcResponse: responseBody as JsonDict, + }; + }, + }; +} diff --git a/src/client/rpc-client.ts b/src/client/rpc-client.ts deleted file mode 100644 index 2d42eb9c..00000000 --- a/src/client/rpc-client.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { JsonDict } from '../types'; -import { StackOneAPIError } from '../utils/errors'; - -/** - * RPC action request payload - */ -export interface RpcActionRequest { - action: string; - body?: JsonDict; - headers?: Record; - path?: JsonDict; - query?: JsonDict; -} - -/** - * RPC action response from the StackOne API - */ -export interface RpcActionResponse { - actionsRpcResponse?: JsonDict; -} - -/** - * Configuration for the RPC client - */ -export interface RpcClientConfig { - serverURL?: string; - security: { - username: string; - password?: string; - }; -} - -/** - * Custom RPC client for StackOne API - * Replaces the @stackone/stackone-client-ts dependency - */ -export class RpcClient { - private readonly baseUrl: string; - private readonly authHeader: string; - - constructor(config: RpcClientConfig) { - this.baseUrl = config.serverURL || 'https://api.stackone.com'; - const username = config.security.username; - const password = config.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 - */ - rpcAction: async (request: RpcActionRequest): Promise => { - const url = `${this.baseUrl}/actions/rpc`; - - const requestBody = { - action: request.action, - body: request.body, - headers: request.headers, - path: request.path, - query: request.query, - }; - - 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().catch(() => ({})); - - if (!response.ok) { - throw new StackOneAPIError( - `RPC action failed for ${url}`, - response.status, - responseBody, - requestBody - ); - } - - return { - actionsRpcResponse: responseBody as JsonDict, - }; - }, - }; -} From 928562acd42e5b75948a65708143554be481db20 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:19:31 +0000 Subject: [PATCH 03/15] refactor: flatten client directory structure - Move src/client/index.ts to src/rpc-client.ts - Move src/client/index.spec.ts to src/rpc-client.spec.ts - Rename src/mcp.ts to src/mcp-client.ts - Update import paths in base.ts and test files - Remove src/client/ directory This simplifies the directory structure by placing small client modules at the src/ root level rather than in nested directories. --- src/{mcp.ts => mcp-client.ts} | 0 src/{client/index.spec.ts => rpc-client.spec.ts} | 4 ++-- src/{client/index.ts => rpc-client.ts} | 4 ++-- src/toolsets/base.ts | 4 ++-- src/toolsets/tests/stackone.mcp-fetch.spec.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) rename src/{mcp.ts => mcp-client.ts} (100%) rename src/{client/index.spec.ts => rpc-client.spec.ts} (97%) rename src/{client/index.ts => rpc-client.ts} (95%) 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/client/index.spec.ts b/src/rpc-client.spec.ts similarity index 97% rename from src/client/index.spec.ts rename to src/rpc-client.spec.ts index 0455fd6e..8037c174 100644 --- a/src/client/index.spec.ts +++ b/src/rpc-client.spec.ts @@ -1,5 +1,5 @@ -import { StackOneAPIError } from '../utils/errors'; -import { RpcClient } from './index'; +import { StackOneAPIError } from './utils/errors'; +import { RpcClient } from './rpc-client'; describe('RpcClient', () => { describe('constructor', () => { diff --git a/src/client/index.ts b/src/rpc-client.ts similarity index 95% rename from src/client/index.ts rename to src/rpc-client.ts index 2d42eb9c..1d138add 100644 --- a/src/client/index.ts +++ b/src/rpc-client.ts @@ -1,5 +1,5 @@ -import type { JsonDict } from '../types'; -import { StackOneAPIError } from '../utils/errors'; +import type { JsonDict } from './types'; +import { StackOneAPIError } from './utils/errors'; /** * RPC action request payload diff --git a/src/toolsets/base.ts b/src/toolsets/base.ts index c70ad067..f0fbc62a 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets/base.ts @@ -1,6 +1,6 @@ import type { Arrayable } from 'type-fest'; -import { RpcClient } from '../client'; -import { createMCPClient } from '../mcp'; +import { createMCPClient } from '../mcp-client'; +import { RpcClient } from '../rpc-client'; import { BaseTool, Tools } from '../tool'; import type { ExecuteOptions, diff --git a/src/toolsets/tests/stackone.mcp-fetch.spec.ts b/src/toolsets/tests/stackone.mcp-fetch.spec.ts index 44dc0dd0..dbe3d992 100644 --- a/src/toolsets/tests/stackone.mcp-fetch.spec.ts +++ b/src/toolsets/tests/stackone.mcp-fetch.spec.ts @@ -4,7 +4,7 @@ import { Hono } from 'hono'; import { assert, vi } from 'vitest'; import { z } from 'zod'; import { server as mswServer } from '../../../mocks/node'; -import type { RpcClient } from '../../client'; +import type { RpcClient } from '../../rpc-client'; import { ToolSet } from '../base'; import { StackOneToolSet } from '../stackone'; From ae6009b5651d0db90549964144130b27a8bc996a Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:20:36 +0000 Subject: [PATCH 04/15] refactor(tests): remove redundant outer describe block in rpc-client.spec.ts --- src/rpc-client.spec.ts | 206 ++++++++++++++++++++--------------------- 1 file changed, 102 insertions(+), 104 deletions(-) diff --git a/src/rpc-client.spec.ts b/src/rpc-client.spec.ts index 8037c174..e5ccc047 100644 --- a/src/rpc-client.spec.ts +++ b/src/rpc-client.spec.ts @@ -1,137 +1,135 @@ import { StackOneAPIError } from './utils/errors'; import { RpcClient } from './rpc-client'; -describe('RpcClient', () => { - describe('constructor', () => { - it('should create client with default base URL', () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); - expect(client).toBeInstanceOf(RpcClient); +describe('constructor', () => { + it('should create client with default base URL', () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, }); + expect(client).toBeInstanceOf(RpcClient); + }); - it('should create client with custom server URL', () => { - const client = new RpcClient({ - serverURL: 'https://custom.api.stackone.com', - security: { username: 'test-api-key' }, - }); - expect(client).toBeInstanceOf(RpcClient); + it('should create client with custom server URL', () => { + const client = new RpcClient({ + serverURL: 'https://custom.api.stackone.com', + security: { username: 'test-api-key' }, }); + expect(client).toBeInstanceOf(RpcClient); + }); - it('should create client with username and password', () => { - const client = new RpcClient({ - security: { username: 'test-api-key', password: 'test-password' }, - }); - expect(client).toBeInstanceOf(RpcClient); + it('should create client with username and password', () => { + const client = new RpcClient({ + security: { username: 'test-api-key', password: 'test-password' }, }); + expect(client).toBeInstanceOf(RpcClient); }); +}); - describe('actions.rpcAction', () => { - it('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' }, - }); +describe('actions.rpcAction', () => { + it('should successfully execute an RPC action', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); - expect(response.actionsRpcResponse).toHaveProperty('data'); + const response = await client.actions.rpcAction({ + action: 'hris_get_employee', + body: { fields: 'name,email' }, + path: { id: 'emp-123' }, }); - it('should send correct payload structure', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); + expect(response.actionsRpcResponse).toBeDefined(); + expect(response.actionsRpcResponse).toHaveProperty('data'); + }); - const response = await client.actions.rpcAction({ - action: 'custom_action', - body: { key: 'value' }, - headers: { 'x-custom': 'header' }, - path: { id: '123' }, - query: { filter: 'active' }, - }); + it('should send correct payload structure', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); - const data = response.actionsRpcResponse as { - data: { - action: string; - received: { - body: Record; - headers: Record; - path: Record; - query: Record; - }; - }; - }; - expect(data.data.action).toBe('custom_action'); - expect(data.data.received.body).toEqual({ key: 'value' }); - expect(data.data.received.headers).toEqual({ 'x-custom': 'header' }); - expect(data.data.received.path).toEqual({ id: '123' }); - expect(data.data.received.query).toEqual({ filter: 'active' }); + const response = await client.actions.rpcAction({ + action: 'custom_action', + body: { key: 'value' }, + headers: { 'x-custom': 'header' }, + path: { id: '123' }, + query: { filter: 'active' }, }); - it('should handle list actions', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); + expect(response.actionsRpcResponse).toBeDefined(); + const data = response.actionsRpcResponse as { + data: { + action: string; + received: { + body: Record; + headers: Record; + path: Record; + query: Record; + }; + }; + }; + expect(data.data.action).toBe('custom_action'); + expect(data.data.received.body).toEqual({ key: 'value' }); + expect(data.data.received.headers).toEqual({ 'x-custom': 'header' }); + expect(data.data.received.path).toEqual({ id: '123' }); + expect(data.data.received.query).toEqual({ filter: 'active' }); + }); - const response = await client.actions.rpcAction({ - action: 'hris_list_employees', - }); + it('should handle list actions', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); - const data = response.actionsRpcResponse as { data: Array<{ id: string; name: string }> }; - expect(data.data).toHaveLength(2); - expect(data.data[0]).toHaveProperty('id'); - expect(data.data[0]).toHaveProperty('name'); + const response = await client.actions.rpcAction({ + action: 'hris_list_employees', }); - it('should throw StackOneAPIError on server error', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); + expect(response.actionsRpcResponse).toBeDefined(); + const data = response.actionsRpcResponse as { data: Array<{ id: string; name: string }> }; + expect(data.data).toHaveLength(2); + expect(data.data[0]).toHaveProperty('id'); + expect(data.data[0]).toHaveProperty('name'); + }); - await expect( - client.actions.rpcAction({ - action: 'test_error_action', - }) - ).rejects.toThrow(StackOneAPIError); + it('should throw StackOneAPIError on server error', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, }); - it('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', + }) + ).rejects.toThrow(StackOneAPIError); + }); - try { - await client.actions.rpcAction({ - action: 'test_error_action', - body: { debug: 'data' }, - }); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(StackOneAPIError); - const apiError = error as StackOneAPIError; - expect(apiError.statusCode).toBe(500); - expect(apiError.requestBody).toBeDefined(); - expect(apiError.requestBody).toHaveProperty('action', 'test_error_action'); - } + it('should include request body in error for debugging', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, }); - it('should work with only action parameter', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, + try { + await client.actions.rpcAction({ + action: 'test_error_action', + body: { debug: 'data' }, }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(StackOneAPIError); + const apiError = error as StackOneAPIError; + expect(apiError.statusCode).toBe(500); + expect(apiError.requestBody).toBeDefined(); + expect(apiError.requestBody).toHaveProperty('action', 'test_error_action'); + } + }); - const response = await client.actions.rpcAction({ - action: 'simple_action', - }); + it('should work with only action parameter', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); + const response = await client.actions.rpcAction({ + action: 'simple_action', }); + + expect(response.actionsRpcResponse).toBeDefined(); }); }); From 6b586545f57e8443c8d0c7d14f5cfc7873f7dc87 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:21:24 +0000 Subject: [PATCH 05/15] refactor(tests): remove useless constructor tests from rpc-client.spec.ts --- src/rpc-client.spec.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/rpc-client.spec.ts b/src/rpc-client.spec.ts index e5ccc047..877bf414 100644 --- a/src/rpc-client.spec.ts +++ b/src/rpc-client.spec.ts @@ -1,30 +1,6 @@ import { StackOneAPIError } from './utils/errors'; import { RpcClient } from './rpc-client'; -describe('constructor', () => { - it('should create client with default base URL', () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); - expect(client).toBeInstanceOf(RpcClient); - }); - - it('should create client with custom server URL', () => { - const client = new RpcClient({ - serverURL: 'https://custom.api.stackone.com', - security: { username: 'test-api-key' }, - }); - expect(client).toBeInstanceOf(RpcClient); - }); - - it('should create client with username and password', () => { - const client = new RpcClient({ - security: { username: 'test-api-key', password: 'test-password' }, - }); - expect(client).toBeInstanceOf(RpcClient); - }); -}); - describe('actions.rpcAction', () => { it('should successfully execute an RPC action', async () => { const client = new RpcClient({ From bf9336b02b71d9b5270ecb8caa3373abacc76879 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:22:31 +0000 Subject: [PATCH 06/15] refactor(tests): remove describe block and use test() instead of it() --- src/rpc-client.spec.ts | 170 ++++++++++++++++++++--------------------- 1 file changed, 84 insertions(+), 86 deletions(-) diff --git a/src/rpc-client.spec.ts b/src/rpc-client.spec.ts index 877bf414..e1ad3b73 100644 --- a/src/rpc-client.spec.ts +++ b/src/rpc-client.spec.ts @@ -1,111 +1,109 @@ import { StackOneAPIError } from './utils/errors'; import { RpcClient } from './rpc-client'; -describe('actions.rpcAction', () => { - it('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' }, - }); +test('should successfully execute an RPC action', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); - expect(response.actionsRpcResponse).toHaveProperty('data'); + const response = await client.actions.rpcAction({ + action: 'hris_get_employee', + body: { fields: 'name,email' }, + path: { id: 'emp-123' }, }); - it('should send correct payload structure', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); + expect(response.actionsRpcResponse).toBeDefined(); + expect(response.actionsRpcResponse).toHaveProperty('data'); +}); - const response = await client.actions.rpcAction({ - action: 'custom_action', - body: { key: 'value' }, - headers: { 'x-custom': 'header' }, - path: { id: '123' }, - query: { filter: 'active' }, - }); +test('should send correct payload structure', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); - const data = response.actionsRpcResponse as { - data: { - action: string; - received: { - body: Record; - headers: Record; - path: Record; - query: Record; - }; - }; - }; - expect(data.data.action).toBe('custom_action'); - expect(data.data.received.body).toEqual({ key: 'value' }); - expect(data.data.received.headers).toEqual({ 'x-custom': 'header' }); - expect(data.data.received.path).toEqual({ id: '123' }); - expect(data.data.received.query).toEqual({ filter: 'active' }); + const response = await client.actions.rpcAction({ + action: 'custom_action', + body: { key: 'value' }, + headers: { 'x-custom': 'header' }, + path: { id: '123' }, + query: { filter: 'active' }, }); - it('should handle list actions', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); + expect(response.actionsRpcResponse).toBeDefined(); + const data = response.actionsRpcResponse as { + data: { + action: string; + received: { + body: Record; + headers: Record; + path: Record; + query: Record; + }; + }; + }; + expect(data.data.action).toBe('custom_action'); + expect(data.data.received.body).toEqual({ key: 'value' }); + expect(data.data.received.headers).toEqual({ 'x-custom': 'header' }); + expect(data.data.received.path).toEqual({ id: '123' }); + expect(data.data.received.query).toEqual({ filter: 'active' }); +}); - const response = await client.actions.rpcAction({ - action: 'hris_list_employees', - }); +test('should handle list actions', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); - const data = response.actionsRpcResponse as { data: Array<{ id: string; name: string }> }; - expect(data.data).toHaveLength(2); - expect(data.data[0]).toHaveProperty('id'); - expect(data.data[0]).toHaveProperty('name'); + const response = await client.actions.rpcAction({ + action: 'hris_list_employees', }); - it('should throw StackOneAPIError on server error', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, - }); + expect(response.actionsRpcResponse).toBeDefined(); + const data = response.actionsRpcResponse as { data: Array<{ id: string; name: string }> }; + expect(data.data).toHaveLength(2); + expect(data.data[0]).toHaveProperty('id'); + expect(data.data[0]).toHaveProperty('name'); +}); - await expect( - client.actions.rpcAction({ - action: 'test_error_action', - }) - ).rejects.toThrow(StackOneAPIError); +test('should throw StackOneAPIError on server error', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, }); - it('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', + }) + ).rejects.toThrow(StackOneAPIError); +}); - try { - await client.actions.rpcAction({ - action: 'test_error_action', - body: { debug: 'data' }, - }); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(StackOneAPIError); - const apiError = error as StackOneAPIError; - expect(apiError.statusCode).toBe(500); - expect(apiError.requestBody).toBeDefined(); - expect(apiError.requestBody).toHaveProperty('action', 'test_error_action'); - } +test('should include request body in error for debugging', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, }); - it('should work with only action parameter', async () => { - const client = new RpcClient({ - security: { username: 'test-api-key' }, + try { + await client.actions.rpcAction({ + action: 'test_error_action', + body: { debug: 'data' }, }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(StackOneAPIError); + const apiError = error as StackOneAPIError; + expect(apiError.statusCode).toBe(500); + expect(apiError.requestBody).toBeDefined(); + expect(apiError.requestBody).toHaveProperty('action', 'test_error_action'); + } +}); - const response = await client.actions.rpcAction({ - action: 'simple_action', - }); +test('should work with only action parameter', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); - expect(response.actionsRpcResponse).toBeDefined(); + const response = await client.actions.rpcAction({ + action: 'simple_action', }); + + expect(response.actionsRpcResponse).toBeDefined(); }); From 0afc027a608558e83f24d75481273a73d3fb67f1 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:28:27 +0000 Subject: [PATCH 07/15] feat(rpc-client): add Zod validation for request/response schemas - Add Zod schemas for RpcActionRequest, RpcActionResponse, and RpcClientConfig - Validate config in constructor and request in rpcAction - Validate response body before returning - Use catalog:dev for zod dependency - Remove duplicate catalog entry from pnpm-workspace.yaml --- src/rpc-client.ts | 76 ++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 1d138add..08261685 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -1,34 +1,50 @@ +import { z } from 'zod'; import type { JsonDict } from './types'; import { StackOneAPIError } from './utils/errors'; +/** + * Zod schema for RPC action request validation + */ +const rpcActionRequestSchema = z.object({ + action: z.string(), + body: z.record(z.unknown()).optional(), + headers: z.record(z.string()).optional(), + path: z.record(z.unknown()).optional(), + query: z.record(z.unknown()).optional(), +}); + /** * RPC action request payload */ -export interface RpcActionRequest { - action: string; - body?: JsonDict; - headers?: Record; - path?: JsonDict; - query?: JsonDict; -} +export type RpcActionRequest = z.infer; + +/** + * Zod schema for RPC action response validation + */ +const rpcActionResponseSchema = z.object({ + actionsRpcResponse: z.record(z.unknown()).optional(), +}); /** * RPC action response from the StackOne API */ -export interface RpcActionResponse { - actionsRpcResponse?: JsonDict; -} +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 interface RpcClientConfig { - serverURL?: string; - security: { - username: string; - password?: string; - }; -} +export type RpcClientConfig = z.infer; /** * Custom RPC client for StackOne API @@ -39,9 +55,10 @@ export class RpcClient { private readonly authHeader: string; constructor(config: RpcClientConfig) { - this.baseUrl = config.serverURL || 'https://api.stackone.com'; - const username = config.security.username; - const password = config.security.password || ''; + 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')}`; } @@ -55,14 +72,15 @@ export class RpcClient { * @returns The RPC action response */ rpcAction: async (request: RpcActionRequest): Promise => { + const validatedRequest = rpcActionRequestSchema.parse(request); const url = `${this.baseUrl}/actions/rpc`; const requestBody = { - action: request.action, - body: request.body, - headers: request.headers, - path: request.path, - query: request.query, + action: validatedRequest.action, + body: validatedRequest.body, + headers: validatedRequest.headers, + path: validatedRequest.path, + query: validatedRequest.query, }; const response = await fetch(url, { @@ -75,7 +93,7 @@ export class RpcClient { body: JSON.stringify(requestBody), }); - const responseBody: unknown = await response.json().catch(() => ({})); + const responseBody = await response.json(); if (!response.ok) { throw new StackOneAPIError( @@ -86,8 +104,12 @@ export class RpcClient { ); } + const validated = rpcActionResponseSchema.parse({ + actionsRpcResponse: responseBody, + }); + return { - actionsRpcResponse: responseBody as JsonDict, + actionsRpcResponse: validated.actionsRpcResponse as JsonDict | undefined, }; }, }; From 20b5c7956778986fed948a79293345f8a7fc48f2 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:30:26 +0000 Subject: [PATCH 08/15] refactor(tests): replace type assertions with toMatchObject in rpc-client tests - Use toMatchObject for structural validation instead of 'as' type assertions - Use expect.any(String) for dynamic values - Use rejects.toMatchObject for error property validation - Remove all type casting from test file --- src/rpc-client.spec.ts | 53 ++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/src/rpc-client.spec.ts b/src/rpc-client.spec.ts index e1ad3b73..2a251416 100644 --- a/src/rpc-client.spec.ts +++ b/src/rpc-client.spec.ts @@ -29,23 +29,17 @@ test('should send correct payload structure', async () => { query: { filter: 'active' }, }); - expect(response.actionsRpcResponse).toBeDefined(); - const data = response.actionsRpcResponse as { + expect(response.actionsRpcResponse).toMatchObject({ data: { - action: string; + action: 'custom_action', received: { - body: Record; - headers: Record; - path: Record; - query: Record; - }; - }; - }; - expect(data.data.action).toBe('custom_action'); - expect(data.data.received.body).toEqual({ key: 'value' }); - expect(data.data.received.headers).toEqual({ 'x-custom': 'header' }); - expect(data.data.received.path).toEqual({ id: '123' }); - expect(data.data.received.query).toEqual({ filter: 'active' }); + body: { key: 'value' }, + headers: { 'x-custom': 'header' }, + path: { id: '123' }, + query: { filter: 'active' }, + }, + }, + }); }); test('should handle list actions', async () => { @@ -57,11 +51,12 @@ test('should handle list actions', async () => { action: 'hris_list_employees', }); - expect(response.actionsRpcResponse).toBeDefined(); - const data = response.actionsRpcResponse as { data: Array<{ id: string; name: string }> }; - expect(data.data).toHaveLength(2); - expect(data.data[0]).toHaveProperty('id'); - expect(data.data[0]).toHaveProperty('name'); + expect(response.actionsRpcResponse).toMatchObject({ + data: [ + { id: expect.any(String), name: expect.any(String) }, + { id: expect.any(String), name: expect.any(String) }, + ], + }); }); test('should throw StackOneAPIError on server error', async () => { @@ -81,19 +76,15 @@ test('should include request body in error for debugging', async () => { security: { username: 'test-api-key' }, }); - try { - await client.actions.rpcAction({ + await expect( + client.actions.rpcAction({ action: 'test_error_action', body: { debug: 'data' }, - }); - expect.fail('Should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(StackOneAPIError); - const apiError = error as StackOneAPIError; - expect(apiError.statusCode).toBe(500); - expect(apiError.requestBody).toBeDefined(); - expect(apiError.requestBody).toHaveProperty('action', 'test_error_action'); - } + }) + ).rejects.toMatchObject({ + statusCode: 500, + requestBody: { action: 'test_error_action' }, + }); }); test('should work with only action parameter', async () => { From 08fcbeb289c372d1810cb4530f30596aa7240204 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:36:34 +0000 Subject: [PATCH 09/15] refactor(rpc-client): use safeParse and improve type safety - Use safeParse instead of parse for response validation - Throw StackOneAPIError with context on validation failure - Add 'as const satisfies' for requestBody type narrowing - Remove type assertion from return value --- src/rpc-client.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 08261685..954f3847 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -81,7 +81,7 @@ export class RpcClient { headers: validatedRequest.headers, path: validatedRequest.path, query: validatedRequest.query, - }; + } as const satisfies RpcActionRequest; const response = await fetch(url, { method: 'POST', @@ -104,13 +104,22 @@ export class RpcClient { ); } - const validated = rpcActionResponseSchema.parse({ + const validation = rpcActionResponseSchema.safeParse({ actionsRpcResponse: responseBody, }); - return { - actionsRpcResponse: validated.actionsRpcResponse as JsonDict | undefined, - }; + if (!validation.success) { + throw new StackOneAPIError( + `Invalid RPC action response for ${url}`, + response.status, + responseBody, + requestBody + ); + } + + const { actionsRpcResponse } = validation.data; + + return { actionsRpcResponse }; }, }; } From bcfdcd97124266494fec7588f8a9b7eeafd3c3ca Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:37:43 +0000 Subject: [PATCH 10/15] docs(rpc-client): add API reference links to RpcClient class --- src/rpc-client.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 954f3847..fa1da18c 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -47,8 +47,11 @@ const rpcClientConfigSchema = z.object({ export type RpcClientConfig = z.infer; /** - * Custom RPC client for StackOne API - * Replaces the @stackone/stackone-client-ts dependency + * 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; From 2a1f57f391c58c96751e14903704d8f4af25a837 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:39:37 +0000 Subject: [PATCH 11/15] fix: lint errors - sort imports and remove unused JsonDict import --- src/rpc-client.spec.ts | 2 +- src/rpc-client.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rpc-client.spec.ts b/src/rpc-client.spec.ts index 2a251416..38fa40d4 100644 --- a/src/rpc-client.spec.ts +++ b/src/rpc-client.spec.ts @@ -1,5 +1,5 @@ -import { StackOneAPIError } from './utils/errors'; import { RpcClient } from './rpc-client'; +import { StackOneAPIError } from './utils/errors'; test('should successfully execute an RPC action', async () => { const client = new RpcClient({ diff --git a/src/rpc-client.ts b/src/rpc-client.ts index fa1da18c..a8ba8128 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -1,5 +1,4 @@ import { z } from 'zod'; -import type { JsonDict } from './types'; import { StackOneAPIError } from './utils/errors'; /** From b3e3a9cd145c763a5fa23c065db4885b889a6d63 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:40:55 +0000 Subject: [PATCH 12/15] refactor(tests): use nullish coalescing (??) instead of logical OR (||) --- src/toolsets/tests/stackone.mcp-fetch.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toolsets/tests/stackone.mcp-fetch.spec.ts b/src/toolsets/tests/stackone.mcp-fetch.spec.ts index dbe3d992..758179f0 100644 --- a/src/toolsets/tests/stackone.mcp-fetch.spec.ts +++ b/src/toolsets/tests/stackone.mcp-fetch.spec.ts @@ -27,8 +27,8 @@ async function createMockMcpServer(accountTools: Record { // Get account ID from header - const accountId = c.req.header('x-account-id') || 'default'; - const tools = accountTools[accountId] || []; + const accountId = c.req.header('x-account-id') ?? 'default'; + const tools = accountTools[accountId] ?? []; // Create a new MCP server instance per account const mcp = new McpServer({ name: 'test-mcp', version: '1.0.0' }); From 2b7bfcb1d2e970b4183270e6f8aa173eed581984 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:50:41 +0000 Subject: [PATCH 13/15] fix(rpc-client): align response schema with server's ActionsRpcResponseApiModel The previous implementation wrapped the API response in an artificial `actionsRpcResponse` property which didn't match the actual server response structure from unified-cloud-api. Changes: - Update RpcActionResponseSchema to match server's ActionsRpcResponseApiModel: - `data`: object, array of objects, or null - `next`: optional pagination cursor - Use passthrough() to allow additional connector-specific fields - Fix headers type from Record to Record to match server's ActionsRpcRequestDto - Remove artificial wrapping of response in `actionsRpcResponse` property - Add explicit `unknown` type annotation to response.json() for type safety - Export RpcActionResponseData type for consumers needing the data shape This enables proper type inference without type assertions when consuming the RPC client response. Refs: unified-cloud-api/src/modules/actions/models/actions-rpc-response.api.model.ts --- src/rpc-client.ts | 46 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/rpc-client.ts b/src/rpc-client.ts index a8ba8128..2c5370b9 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -3,11 +3,12 @@ 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.string()).optional(), + headers: z.record(z.unknown()).optional(), path: z.record(z.unknown()).optional(), query: z.record(z.unknown()).optional(), }); @@ -17,15 +18,40 @@ const rpcActionRequestSchema = z.object({ */ 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({ - actionsRpcResponse: z.record(z.unknown()).optional(), -}); +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; @@ -71,7 +97,7 @@ export class RpcClient { /** * Execute an RPC action * @param request The RPC action request - * @returns The RPC action response + * @returns The RPC action response matching server's ActionsRpcResponseApiModel */ rpcAction: async (request: RpcActionRequest): Promise => { const validatedRequest = rpcActionRequestSchema.parse(request); @@ -95,7 +121,7 @@ export class RpcClient { body: JSON.stringify(requestBody), }); - const responseBody = await response.json(); + const responseBody: unknown = await response.json(); if (!response.ok) { throw new StackOneAPIError( @@ -106,9 +132,7 @@ export class RpcClient { ); } - const validation = rpcActionResponseSchema.safeParse({ - actionsRpcResponse: responseBody, - }); + const validation = rpcActionResponseSchema.safeParse(responseBody); if (!validation.success) { throw new StackOneAPIError( @@ -119,9 +143,7 @@ export class RpcClient { ); } - const { actionsRpcResponse } = validation.data; - - return { actionsRpcResponse }; + return validation.data; }, }; } From c3b30e08d65641e0048fc36c69d28943a2b8241f Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:50:49 +0000 Subject: [PATCH 14/15] refactor(toolsets): eliminate type assertion with type-safe conversion Replace `as JsonDict` type assertion with explicit conversion function `rpcResponseToJsonDict()` that iterates over response properties. The RpcActionResponse type uses z.passthrough() which preserves additional fields beyond `data` and `next`. This makes it structurally compatible with Record, but TypeScript's type system requires explicit conversion to maintain type safety. This change ensures no implicit `any` or unsafe type assertions are used when handling RPC responses in the toolset. --- src/toolsets/base.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/toolsets/base.ts b/src/toolsets/base.ts index f0fbc62a..d3a0088f 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets/base.ts @@ -1,6 +1,6 @@ import type { Arrayable } from 'type-fest'; import { createMCPClient } from '../mcp-client'; -import { RpcClient } from '../rpc-client'; +import { RpcClient, type RpcActionResponse } 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']; @@ -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; From 034b905e036f042e192f058ff2b5e6b9799f8756 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:50:56 +0000 Subject: [PATCH 15/15] test(rpc-client): update tests for correct response structure Update test expectations to match the actual server response structure where `data` is a direct property of the response, not nested under `actionsRpcResponse`. Changes: - Access response.data directly instead of response.actionsRpcResponse - Add explicit assertions for response data content - Rename test to clarify array data handling - Add comments explaining response structure alignment with server --- src/rpc-client.spec.ts | 42 +++++++++++++++++++++++------------------- src/toolsets/base.ts | 2 +- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/rpc-client.spec.ts b/src/rpc-client.spec.ts index 38fa40d4..cf69b698 100644 --- a/src/rpc-client.spec.ts +++ b/src/rpc-client.spec.ts @@ -12,8 +12,12 @@ test('should successfully execute an RPC action', async () => { path: { id: 'emp-123' }, }); - expect(response.actionsRpcResponse).toBeDefined(); - expect(response.actionsRpcResponse).toHaveProperty('data'); + // 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 () => { @@ -29,20 +33,19 @@ test('should send correct payload structure', async () => { query: { filter: 'active' }, }); - expect(response.actionsRpcResponse).toMatchObject({ - data: { - action: 'custom_action', - received: { - 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', async () => { +test('should handle list actions with array data', async () => { const client = new RpcClient({ security: { username: 'test-api-key' }, }); @@ -51,12 +54,12 @@ test('should handle list actions', async () => { action: 'hris_list_employees', }); - expect(response.actionsRpcResponse).toMatchObject({ - data: [ - { id: expect.any(String), name: expect.any(String) }, - { id: expect.any(String), name: expect.any(String) }, - ], - }); + // 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 () => { @@ -96,5 +99,6 @@ test('should work with only action parameter', async () => { action: 'simple_action', }); - expect(response.actionsRpcResponse).toBeDefined(); + // Response has data field (server returns { data: { action, received } }) + expect(response).toHaveProperty('data'); }); diff --git a/src/toolsets/base.ts b/src/toolsets/base.ts index d3a0088f..b98dfcff 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets/base.ts @@ -1,6 +1,6 @@ import type { Arrayable } from 'type-fest'; import { createMCPClient } from '../mcp-client'; -import { RpcClient, type RpcActionResponse } from '../rpc-client'; +import { type RpcActionResponse, RpcClient } from '../rpc-client'; import { BaseTool, Tools } from '../tool'; import type { ExecuteOptions,