Skip to content

Commit 7365e0b

Browse files
committed
merge: integrate main branch with flattened structure
- Resolve merge conflicts adapting new headers module to flat structure - Flatten src/schemas/headers.ts and src/utils/headers.ts into src/headers.ts - Update imports in rpc-client.ts, rpc-client.test.ts, and toolsets.ts - Add normaliseHeaders function and tests to consolidated headers module
2 parents 55a56ad + b3843a5 commit 7365e0b

File tree

10 files changed

+197
-23
lines changed

10 files changed

+197
-23
lines changed

mocks/handlers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ export const handlers = [
233233
// ============================================================
234234
http.post('https://api.stackone.com/actions/rpc', async ({ request }) => {
235235
const authHeader = request.headers.get('Authorization');
236+
const accountIdHeader = request.headers.get('x-account-id');
236237

237238
// Check for authentication
238239
if (!authHeader || !authHeader.startsWith('Basic ')) {
@@ -258,6 +259,16 @@ export const handlers = [
258259
);
259260
}
260261

262+
// Test action to verify x-account-id is sent as HTTP header
263+
if (body.action === 'test_account_id_header') {
264+
return HttpResponse.json({
265+
data: {
266+
httpHeader: accountIdHeader,
267+
bodyHeader: body.headers?.['x-account-id'],
268+
},
269+
});
270+
}
271+
261272
// Return mock response based on action
262273
if (body.action === 'hris_get_employee') {
263274
return HttpResponse.json({

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"dependencies": {
3838
"@modelcontextprotocol/sdk": "catalog:prod",
3939
"@orama/orama": "catalog:prod",
40+
"defu": "catalog:prod",
4041
"json-schema": "catalog:prod",
4142
"zod": "catalog:dev"
4243
},

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ catalogs:
3333
prod:
3434
'@modelcontextprotocol/sdk': ^1.24.3
3535
'@orama/orama': ^3.1.11
36+
defu: ^6.1.4
3637
json-schema: ^0.4.0
3738

3839
enablePrePostScripts: true

src/headers.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { normaliseHeaders } from './headers';
3+
4+
describe('normaliseHeaders', () => {
5+
it('returns empty object for undefined input', () => {
6+
expect(normaliseHeaders(undefined)).toEqual({});
7+
});
8+
9+
it('returns empty object for empty input', () => {
10+
expect(normaliseHeaders({})).toEqual({});
11+
});
12+
13+
it('preserves string values', () => {
14+
expect(normaliseHeaders({ foo: 'bar', baz: 'qux' })).toEqual({
15+
foo: 'bar',
16+
baz: 'qux',
17+
});
18+
});
19+
20+
it('converts numbers to strings', () => {
21+
expect(normaliseHeaders({ port: 8080, timeout: 30 })).toEqual({
22+
port: '8080',
23+
timeout: '30',
24+
});
25+
});
26+
27+
it('converts booleans to strings', () => {
28+
expect(normaliseHeaders({ enabled: true, debug: false })).toEqual({
29+
enabled: 'true',
30+
debug: 'false',
31+
});
32+
});
33+
34+
it('serialises objects to JSON', () => {
35+
expect(normaliseHeaders({ config: { key: 'value' } })).toEqual({
36+
config: '{"key":"value"}',
37+
});
38+
});
39+
40+
it('serialises arrays to JSON', () => {
41+
expect(normaliseHeaders({ tags: ['foo', 'bar'] })).toEqual({
42+
tags: '["foo","bar"]',
43+
});
44+
});
45+
46+
it('skips undefined values', () => {
47+
expect(normaliseHeaders({ foo: 'bar', baz: undefined })).toEqual({
48+
foo: 'bar',
49+
});
50+
});
51+
52+
it('skips null values', () => {
53+
expect(normaliseHeaders({ foo: 'bar', baz: null })).toEqual({
54+
foo: 'bar',
55+
});
56+
});
57+
58+
it('handles mixed value types', () => {
59+
expect(
60+
normaliseHeaders({
61+
string: 'text',
62+
number: 42,
63+
boolean: true,
64+
object: { nested: 'value' },
65+
array: [1, 2, 3],
66+
nullValue: null,
67+
undefinedValue: undefined,
68+
}),
69+
).toEqual({
70+
string: 'text',
71+
number: '42',
72+
boolean: 'true',
73+
object: '{"nested":"value"}',
74+
array: '[1,2,3]',
75+
});
76+
});
77+
});

src/headers.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from 'zod/mini';
2+
import type { JsonDict } from './types';
3+
4+
/**
5+
* Known StackOne API header keys that are forwarded as HTTP headers
6+
*/
7+
export const STACKONE_HEADER_KEYS = ['x-account-id'] as const;
8+
9+
/**
10+
* Zod schema for StackOne API headers (branded)
11+
* These headers are forwarded as HTTP headers in API requests
12+
*/
13+
export const stackOneHeadersSchema = z.record(z.string(), z.string()).brand<'StackOneHeaders'>();
14+
15+
/**
16+
* Branded type for StackOne API headers
17+
*/
18+
export type StackOneHeaders = z.infer<typeof stackOneHeadersSchema>;
19+
20+
/**
21+
* Normalises header values from JsonDict to StackOneHeaders (branded type)
22+
* Converts numbers and booleans to strings, and serialises objects to JSON
23+
*
24+
* @param headers - Headers object with unknown value types
25+
* @returns Normalised headers with string values only (branded type)
26+
*/
27+
export function normaliseHeaders(headers: JsonDict | undefined): StackOneHeaders {
28+
if (!headers) return stackOneHeadersSchema.parse({});
29+
const result: Record<string, string> = {};
30+
for (const [key, value] of Object.entries(headers)) {
31+
switch (true) {
32+
case value == null:
33+
continue;
34+
case typeof value === 'string':
35+
result[key] = value;
36+
break;
37+
case typeof value === 'number' || typeof value === 'boolean':
38+
result[key] = String(value);
39+
break;
40+
default:
41+
result[key] = JSON.stringify(value);
42+
break;
43+
}
44+
}
45+
return stackOneHeadersSchema.parse(result);
46+
}

src/rpc-client.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RpcClient } from './rpc-client';
2+
import { stackOneHeadersSchema } from './headers';
23
import { StackOneAPIError } from './utils/errors';
34

45
test('should successfully execute an RPC action', async () => {
@@ -28,7 +29,7 @@ test('should send correct payload structure', async () => {
2829
const response = await client.actions.rpcAction({
2930
action: 'custom_action',
3031
body: { key: 'value' },
31-
headers: { 'x-custom': 'header' },
32+
headers: stackOneHeadersSchema.parse({ 'x-custom': 'header' }),
3233
path: { id: '123' },
3334
query: { filter: 'active' },
3435
});
@@ -102,3 +103,20 @@ test('should work with only action parameter', async () => {
102103
// Response has data field (server returns { data: { action, received } })
103104
expect(response).toHaveProperty('data');
104105
});
106+
107+
test('should send x-account-id as HTTP header', async () => {
108+
const client = new RpcClient({
109+
security: { username: 'test-api-key' },
110+
});
111+
112+
const response = await client.actions.rpcAction({
113+
action: 'test_account_id_header',
114+
headers: stackOneHeadersSchema.parse({ 'x-account-id': 'test-account-123' }),
115+
});
116+
117+
// Verify x-account-id is sent both as HTTP header and in request body
118+
expect(response.data).toMatchObject({
119+
httpHeader: 'test-account-123',
120+
bodyHeader: 'test-account-123',
121+
});
122+
});

src/rpc-client.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { STACKONE_HEADER_KEYS } from './headers';
12
import {
23
type RpcActionRequest,
34
type RpcActionResponse,
@@ -53,13 +54,27 @@ export class RpcClient {
5354
query: validatedRequest.query,
5455
} as const satisfies RpcActionRequest;
5556

57+
// Forward StackOne-specific headers as HTTP headers
58+
const requestHeaders = validatedRequest.headers;
59+
const forwardedHeaders: Record<string, string> = {};
60+
if (requestHeaders) {
61+
for (const key of STACKONE_HEADER_KEYS) {
62+
const value = requestHeaders[key];
63+
if (value !== undefined) {
64+
forwardedHeaders[key] = value;
65+
}
66+
}
67+
}
68+
const httpHeaders = {
69+
'Content-Type': 'application/json',
70+
Authorization: this.authHeader,
71+
'User-Agent': 'stackone-ai-node',
72+
...forwardedHeaders,
73+
} satisfies Record<string, string>;
74+
5675
const response = await fetch(url, {
5776
method: 'POST',
58-
headers: {
59-
'Content-Type': 'application/json',
60-
Authorization: this.authHeader,
61-
'User-Agent': 'stackone-ai-node',
62-
},
77+
headers: httpHeaders,
6378
body: JSON.stringify(requestBody),
6479
});
6580

src/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod/mini';
2+
import { stackOneHeadersSchema } from './headers';
23

34
/**
45
* Zod schema for RPC action request validation
@@ -7,7 +8,7 @@ import { z } from 'zod/mini';
78
export const rpcActionRequestSchema = z.object({
89
action: z.string(),
910
body: z.optional(z.record(z.string(), z.unknown())),
10-
headers: z.optional(z.record(z.string(), z.unknown())),
11+
headers: z.optional(stackOneHeadersSchema),
1112
path: z.optional(z.record(z.string(), z.unknown())),
1213
query: z.optional(z.record(z.string(), z.unknown())),
1314
});

src/toolsets.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { defu } from 'defu';
12
import type { Arrayable } from 'type-fest';
23
import { DEFAULT_BASE_URL } from './consts';
4+
import { createFeedbackTool } from './feedback';
5+
import { type StackOneHeaders, normaliseHeaders, stackOneHeadersSchema } from './headers';
36
import { createMCPClient } from './mcp-client';
47
import { type RpcActionResponse, RpcClient } from './rpc-client';
58
import { BaseTool, type StackOneTool, Tools } from './tool';
6-
import { createFeedbackTool } from './feedback';
79
import type {
810
ExecuteOptions,
911
JsonDict,
@@ -473,23 +475,14 @@ export class StackOneToolSet {
473475
typeof inputParams === 'string' ? JSON.parse(inputParams) : (inputParams ?? {});
474476

475477
const currentHeaders = tool.getHeaders();
476-
const actionHeaders = this.buildActionHeaders(currentHeaders);
478+
const baseHeaders = this.buildActionHeaders(currentHeaders);
477479

478480
const pathParams = this.extractRecord(parsedParams, 'path');
479481
const queryParams = this.extractRecord(parsedParams, 'query');
480482
const additionalHeaders = this.extractRecord(parsedParams, 'headers');
481-
if (additionalHeaders) {
482-
for (const [key, value] of Object.entries(additionalHeaders)) {
483-
if (value === undefined || value === null) continue;
484-
if (typeof value === 'string') {
485-
actionHeaders[key] = value;
486-
} else if (typeof value === 'number' || typeof value === 'boolean') {
487-
actionHeaders[key] = String(value);
488-
} else {
489-
actionHeaders[key] = JSON.stringify(value);
490-
}
491-
}
492-
}
483+
const extraHeaders = normaliseHeaders(additionalHeaders);
484+
// defu merges extraHeaders into baseHeaders, both are already branded types
485+
const actionHeaders = defu(extraHeaders, baseHeaders) as StackOneHeaders;
493486

494487
const bodyPayload = this.extractRecord(parsedParams, 'body');
495488
const rpcBody: JsonDict = bodyPayload ? { ...bodyPayload } : {};
@@ -538,12 +531,14 @@ export class StackOneToolSet {
538531
return tool;
539532
}
540533

541-
private buildActionHeaders(headers: Record<string, string>): Record<string, string> {
534+
private buildActionHeaders(headers: Record<string, string>): StackOneHeaders {
542535
const sanitizedEntries = Object.entries(headers).filter(
543536
([key]) => key.toLowerCase() !== 'authorization',
544537
);
545538

546-
return Object.fromEntries(sanitizedEntries.map(([key, value]) => [key, String(value)]));
539+
return stackOneHeadersSchema.parse(
540+
Object.fromEntries(sanitizedEntries.map(([key, value]) => [key, String(value)])),
541+
);
547542
}
548543

549544
private extractRecord(

0 commit comments

Comments
 (0)