diff --git a/mocks/handlers.ts b/mocks/handlers.ts index 779d922..f954355 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 8fd6c81..cfbd9c1 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 13f8310..68c8401 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 b621a0a..9e4a29f 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 718ac3e..f70d025 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 6716cef..b928a7a 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 0000000..878363b --- /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 8769b42..bbed94f 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 21cd097..c3168b7 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 0000000..fd80e15 --- /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 0000000..844091d --- /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); +}