From 8666db43f510d7505fcea76f4bca378719ca04de Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:17:22 +0000 Subject: [PATCH] fix(rpc): send x-account-id as HTTP header in RPC requests The RPC client was sending x-account-id only in the request body's headers field, but the StackOne API expects it as an actual HTTP header. This caused 400 "Missing x-account-id header" errors when executing tools via the RPC endpoint. Changes: - Create src/schemas/headers.ts with shared header types and constants - Define STACKONE_HEADER_KEYS array for known headers to forward - Use Zod schema (stackOneHeadersSchema) for type-safe header validation - Forward all StackOne-specific headers as HTTP headers in RPC requests - Export Headers type from types.ts for consistency The fix extracts known StackOne headers from the request and includes them as HTTP headers on the fetch request while preserving them in the body for backwards compatibility. --- mocks/handlers.ts | 11 ++++++ package.json | 1 + pnpm-lock.yaml | 9 +++++ pnpm-workspace.yaml | 1 + src/rpc-client.test.ts | 20 +++++++++- src/rpc-client.ts | 25 ++++++++++--- src/schemas/headers.ts | 17 +++++++++ src/schemas/rpc.ts | 3 +- src/toolsets/base.ts | 26 ++++++------- src/utils/headers.test.ts | 77 +++++++++++++++++++++++++++++++++++++++ src/utils/headers.ts | 30 +++++++++++++++ 11 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 src/schemas/headers.ts create mode 100644 src/utils/headers.test.ts create mode 100644 src/utils/headers.ts diff --git a/mocks/handlers.ts b/mocks/handlers.ts index 779d922d..f9543552 100644 --- a/mocks/handlers.ts +++ b/mocks/handlers.ts @@ -233,6 +233,7 @@ export const handlers = [ // ============================================================ http.post('https://api.stackone.com/actions/rpc', async ({ request }) => { const authHeader = request.headers.get('Authorization'); + const accountIdHeader = request.headers.get('x-account-id'); // Check for authentication if (!authHeader || !authHeader.startsWith('Basic ')) { @@ -258,6 +259,16 @@ export const handlers = [ ); } + // Test action to verify x-account-id is sent as HTTP header + if (body.action === 'test_account_id_header') { + return HttpResponse.json({ + data: { + httpHeader: accountIdHeader, + bodyHeader: body.headers?.['x-account-id'], + }, + }); + } + // Return mock response based on action if (body.action === 'hris_get_employee') { return HttpResponse.json({ diff --git a/package.json b/package.json index 8fd6c817..cfbd9c1f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "catalog:prod", "@orama/orama": "catalog:prod", + "defu": "catalog:prod", "json-schema": "catalog:prod", "zod": "catalog:dev" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13f8310a..68c84012 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: dev: + '@ai-sdk/openai': + specifier: ^2.0.80 + version: 2.0.80 '@ai-sdk/provider-utils': specifier: ^3.0.18 version: 3.0.18 @@ -80,6 +83,9 @@ catalogs: '@orama/orama': specifier: ^3.1.11 version: 3.1.16 + defu: + specifier: ^6.1.4 + version: 6.1.4 json-schema: specifier: ^0.4.0 version: 0.4.0 @@ -93,6 +99,9 @@ importers: '@orama/orama': specifier: catalog:prod version: 3.1.16 + defu: + specifier: catalog:prod + version: 6.1.4 json-schema: specifier: catalog:prod version: 0.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b621a0ac..9e4a29fe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,6 +33,7 @@ catalogs: prod: '@modelcontextprotocol/sdk': ^1.24.3 '@orama/orama': ^3.1.11 + defu: ^6.1.4 json-schema: ^0.4.0 enablePrePostScripts: true diff --git a/src/rpc-client.test.ts b/src/rpc-client.test.ts index 718ac3e6..f70d0255 100644 --- a/src/rpc-client.test.ts +++ b/src/rpc-client.test.ts @@ -1,4 +1,5 @@ import { RpcClient } from './rpc-client'; +import { stackOneHeadersSchema } from './schemas/headers'; import { StackOneAPIError } from './utils/errors'; test('should successfully execute an RPC action', async () => { @@ -28,7 +29,7 @@ test('should send correct payload structure', async () => { const response = await client.actions.rpcAction({ action: 'custom_action', body: { key: 'value' }, - headers: { 'x-custom': 'header' }, + headers: stackOneHeadersSchema.parse({ 'x-custom': 'header' }), path: { id: '123' }, query: { filter: 'active' }, }); @@ -102,3 +103,20 @@ test('should work with only action parameter', async () => { // Response has data field (server returns { data: { action, received } }) expect(response).toHaveProperty('data'); }); + +test('should send x-account-id as HTTP header', async () => { + const client = new RpcClient({ + security: { username: 'test-api-key' }, + }); + + const response = await client.actions.rpcAction({ + action: 'test_account_id_header', + headers: stackOneHeadersSchema.parse({ 'x-account-id': 'test-account-123' }), + }); + + // Verify x-account-id is sent both as HTTP header and in request body + expect(response.data).toMatchObject({ + httpHeader: 'test-account-123', + bodyHeader: 'test-account-123', + }); +}); diff --git a/src/rpc-client.ts b/src/rpc-client.ts index 6716cefd..b928a7a3 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -1,3 +1,4 @@ +import { STACKONE_HEADER_KEYS } from './schemas/headers'; import { type RpcActionRequest, type RpcActionResponse, @@ -53,13 +54,27 @@ export class RpcClient { query: validatedRequest.query, } as const satisfies RpcActionRequest; + // Forward StackOne-specific headers as HTTP headers + const requestHeaders = validatedRequest.headers; + const forwardedHeaders: Record = {}; + if (requestHeaders) { + for (const key of STACKONE_HEADER_KEYS) { + const value = requestHeaders[key]; + if (value !== undefined) { + forwardedHeaders[key] = value; + } + } + } + const httpHeaders = { + 'Content-Type': 'application/json', + Authorization: this.authHeader, + 'User-Agent': 'stackone-ai-node', + ...forwardedHeaders, + } satisfies Record; + const response = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.authHeader, - 'User-Agent': 'stackone-ai-node', - }, + headers: httpHeaders, body: JSON.stringify(requestBody), }); diff --git a/src/schemas/headers.ts b/src/schemas/headers.ts new file mode 100644 index 00000000..878363b1 --- /dev/null +++ b/src/schemas/headers.ts @@ -0,0 +1,17 @@ +import { z } from 'zod/mini'; + +/** + * Known StackOne API header keys that are forwarded as HTTP headers + */ +export const STACKONE_HEADER_KEYS = ['x-account-id'] as const; + +/** + * Zod schema for StackOne API headers (branded) + * These headers are forwarded as HTTP headers in API requests + */ +export const stackOneHeadersSchema = z.record(z.string(), z.string()).brand<'StackOneHeaders'>(); + +/** + * Branded type for StackOne API headers + */ +export type StackOneHeaders = z.infer; diff --git a/src/schemas/rpc.ts b/src/schemas/rpc.ts index 8769b421..bbed94fa 100644 --- a/src/schemas/rpc.ts +++ b/src/schemas/rpc.ts @@ -1,4 +1,5 @@ import { z } from 'zod/mini'; +import { stackOneHeadersSchema } from './headers'; /** * Zod schema for RPC action request validation @@ -7,7 +8,7 @@ import { z } from 'zod/mini'; export const rpcActionRequestSchema = z.object({ action: z.string(), body: z.optional(z.record(z.string(), z.unknown())), - headers: z.optional(z.record(z.string(), z.unknown())), + headers: z.optional(stackOneHeadersSchema), path: z.optional(z.record(z.string(), z.unknown())), query: z.optional(z.record(z.string(), z.unknown())), }); diff --git a/src/toolsets/base.ts b/src/toolsets/base.ts index 21cd0972..c3168b72 100644 --- a/src/toolsets/base.ts +++ b/src/toolsets/base.ts @@ -1,6 +1,8 @@ +import { defu } from 'defu'; import type { Arrayable } from 'type-fest'; import { createMCPClient } from '../mcp-client'; import { type RpcActionResponse, RpcClient } from '../rpc-client'; +import { type StackOneHeaders, stackOneHeadersSchema } from '../schemas/headers'; import { BaseTool, Tools } from '../tool'; import type { ExecuteOptions, @@ -11,6 +13,7 @@ import type { } from '../types'; import { toArray } from '../utils/array'; import { StackOneError } from '../utils/errors'; +import { normaliseHeaders } from '../utils/headers'; /** * Converts RpcActionResponse to JsonDict in a type-safe manner. @@ -304,23 +307,14 @@ export abstract class ToolSet { typeof inputParams === 'string' ? JSON.parse(inputParams) : (inputParams ?? {}); const currentHeaders = tool.getHeaders(); - const actionHeaders = this.buildActionHeaders(currentHeaders); + const baseHeaders = this.buildActionHeaders(currentHeaders); const pathParams = this.extractRecord(parsedParams, 'path'); const queryParams = this.extractRecord(parsedParams, 'query'); const additionalHeaders = this.extractRecord(parsedParams, 'headers'); - if (additionalHeaders) { - for (const [key, value] of Object.entries(additionalHeaders)) { - if (value === undefined || value === null) continue; - if (typeof value === 'string') { - actionHeaders[key] = value; - } else if (typeof value === 'number' || typeof value === 'boolean') { - actionHeaders[key] = String(value); - } else { - actionHeaders[key] = JSON.stringify(value); - } - } - } + const extraHeaders = normaliseHeaders(additionalHeaders); + // defu merges extraHeaders into baseHeaders, both are already branded types + const actionHeaders = defu(extraHeaders, baseHeaders) as StackOneHeaders; const bodyPayload = this.extractRecord(parsedParams, 'body'); const rpcBody: JsonDict = bodyPayload ? { ...bodyPayload } : {}; @@ -369,12 +363,14 @@ export abstract class ToolSet { return tool; } - private buildActionHeaders(headers: Record): Record { + private buildActionHeaders(headers: Record): StackOneHeaders { const sanitizedEntries = Object.entries(headers).filter( ([key]) => key.toLowerCase() !== 'authorization', ); - return Object.fromEntries(sanitizedEntries.map(([key, value]) => [key, String(value)])); + return stackOneHeadersSchema.parse( + Object.fromEntries(sanitizedEntries.map(([key, value]) => [key, String(value)])), + ); } private extractRecord( diff --git a/src/utils/headers.test.ts b/src/utils/headers.test.ts new file mode 100644 index 00000000..fd80e159 --- /dev/null +++ b/src/utils/headers.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { normaliseHeaders } from './headers'; + +describe('normaliseHeaders', () => { + it('returns empty object for undefined input', () => { + expect(normaliseHeaders(undefined)).toEqual({}); + }); + + it('returns empty object for empty input', () => { + expect(normaliseHeaders({})).toEqual({}); + }); + + it('preserves string values', () => { + expect(normaliseHeaders({ foo: 'bar', baz: 'qux' })).toEqual({ + foo: 'bar', + baz: 'qux', + }); + }); + + it('converts numbers to strings', () => { + expect(normaliseHeaders({ port: 8080, timeout: 30 })).toEqual({ + port: '8080', + timeout: '30', + }); + }); + + it('converts booleans to strings', () => { + expect(normaliseHeaders({ enabled: true, debug: false })).toEqual({ + enabled: 'true', + debug: 'false', + }); + }); + + it('serialises objects to JSON', () => { + expect(normaliseHeaders({ config: { key: 'value' } })).toEqual({ + config: '{"key":"value"}', + }); + }); + + it('serialises arrays to JSON', () => { + expect(normaliseHeaders({ tags: ['foo', 'bar'] })).toEqual({ + tags: '["foo","bar"]', + }); + }); + + it('skips undefined values', () => { + expect(normaliseHeaders({ foo: 'bar', baz: undefined })).toEqual({ + foo: 'bar', + }); + }); + + it('skips null values', () => { + expect(normaliseHeaders({ foo: 'bar', baz: null })).toEqual({ + foo: 'bar', + }); + }); + + it('handles mixed value types', () => { + expect( + normaliseHeaders({ + string: 'text', + number: 42, + boolean: true, + object: { nested: 'value' }, + array: [1, 2, 3], + nullValue: null, + undefinedValue: undefined, + }), + ).toEqual({ + string: 'text', + number: '42', + boolean: 'true', + object: '{"nested":"value"}', + array: '[1,2,3]', + }); + }); +}); diff --git a/src/utils/headers.ts b/src/utils/headers.ts new file mode 100644 index 00000000..844091d6 --- /dev/null +++ b/src/utils/headers.ts @@ -0,0 +1,30 @@ +import { type StackOneHeaders, stackOneHeadersSchema } from '../schemas/headers'; +import type { JsonDict } from '../types'; + +/** + * Normalises header values from JsonDict to StackOneHeaders (branded type) + * Converts numbers and booleans to strings, and serialises objects to JSON + * + * @param headers - Headers object with unknown value types + * @returns Normalised headers with string values only (branded type) + */ +export function normaliseHeaders(headers: JsonDict | undefined): StackOneHeaders { + if (!headers) return stackOneHeadersSchema.parse({}); + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + switch (true) { + case value == null: + continue; + case typeof value === 'string': + result[key] = value; + break; + case typeof value === 'number' || typeof value === 'boolean': + result[key] = String(value); + break; + default: + result[key] = JSON.stringify(value); + break; + } + } + return stackOneHeadersSchema.parse(result); +}