Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ')) {
Expand All @@ -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({
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "catalog:prod",
"@orama/orama": "catalog:prod",
"defu": "catalog:prod",
"json-schema": "catalog:prod",
"zod": "catalog:dev"
},
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/rpc-client.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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' },
});
Expand Down Expand Up @@ -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',
});
});
25 changes: 20 additions & 5 deletions src/rpc-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { STACKONE_HEADER_KEYS } from './schemas/headers';
import {
type RpcActionRequest,
type RpcActionResponse,
Expand Down Expand Up @@ -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<string, string> = {};
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<string, string>;

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),
});

Expand Down
17 changes: 17 additions & 0 deletions src/schemas/headers.ts
Original file line number Diff line number Diff line change
@@ -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<typeof stackOneHeadersSchema>;
3 changes: 2 additions & 1 deletion src/schemas/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod/mini';
import { stackOneHeadersSchema } from './headers';

/**
* Zod schema for RPC action request validation
Expand All @@ -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())),
});
Expand Down
26 changes: 11 additions & 15 deletions src/toolsets/base.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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 } : {};
Expand Down Expand Up @@ -369,12 +363,14 @@ export abstract class ToolSet {
return tool;
}

private buildActionHeaders(headers: Record<string, string>): Record<string, string> {
private buildActionHeaders(headers: Record<string, string>): 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(
Expand Down
77 changes: 77 additions & 0 deletions src/utils/headers.test.ts
Original file line number Diff line number Diff line change
@@ -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]',
});
});
});
30 changes: 30 additions & 0 deletions src/utils/headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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);
}
Loading