Skip to content

Commit b3843a5

Browse files
authored
fix(rpc): send x-account-id as HTTP header in RPC requests (#202)
1 parent 5df53c0 commit b3843a5

File tree

11 files changed

+198
-22
lines changed

11 files changed

+198
-22
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/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 './schemas/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 './schemas/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/schemas/headers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from 'zod/mini';
2+
3+
/**
4+
* Known StackOne API header keys that are forwarded as HTTP headers
5+
*/
6+
export const STACKONE_HEADER_KEYS = ['x-account-id'] as const;
7+
8+
/**
9+
* Zod schema for StackOne API headers (branded)
10+
* These headers are forwarded as HTTP headers in API requests
11+
*/
12+
export const stackOneHeadersSchema = z.record(z.string(), z.string()).brand<'StackOneHeaders'>();
13+
14+
/**
15+
* Branded type for StackOne API headers
16+
*/
17+
export type StackOneHeaders = z.infer<typeof stackOneHeadersSchema>;

src/schemas/rpc.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/base.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { defu } from 'defu';
12
import type { Arrayable } from 'type-fest';
23
import { createMCPClient } from '../mcp-client';
34
import { type RpcActionResponse, RpcClient } from '../rpc-client';
5+
import { type StackOneHeaders, stackOneHeadersSchema } from '../schemas/headers';
46
import { BaseTool, Tools } from '../tool';
57
import type {
68
ExecuteOptions,
@@ -11,6 +13,7 @@ import type {
1113
} from '../types';
1214
import { toArray } from '../utils/array';
1315
import { StackOneError } from '../utils/errors';
16+
import { normaliseHeaders } from '../utils/headers';
1417

1518
/**
1619
* Converts RpcActionResponse to JsonDict in a type-safe manner.
@@ -304,23 +307,14 @@ export abstract class ToolSet {
304307
typeof inputParams === 'string' ? JSON.parse(inputParams) : (inputParams ?? {});
305308

306309
const currentHeaders = tool.getHeaders();
307-
const actionHeaders = this.buildActionHeaders(currentHeaders);
310+
const baseHeaders = this.buildActionHeaders(currentHeaders);
308311

309312
const pathParams = this.extractRecord(parsedParams, 'path');
310313
const queryParams = this.extractRecord(parsedParams, 'query');
311314
const additionalHeaders = this.extractRecord(parsedParams, 'headers');
312-
if (additionalHeaders) {
313-
for (const [key, value] of Object.entries(additionalHeaders)) {
314-
if (value === undefined || value === null) continue;
315-
if (typeof value === 'string') {
316-
actionHeaders[key] = value;
317-
} else if (typeof value === 'number' || typeof value === 'boolean') {
318-
actionHeaders[key] = String(value);
319-
} else {
320-
actionHeaders[key] = JSON.stringify(value);
321-
}
322-
}
323-
}
315+
const extraHeaders = normaliseHeaders(additionalHeaders);
316+
// defu merges extraHeaders into baseHeaders, both are already branded types
317+
const actionHeaders = defu(extraHeaders, baseHeaders) as StackOneHeaders;
324318

325319
const bodyPayload = this.extractRecord(parsedParams, 'body');
326320
const rpcBody: JsonDict = bodyPayload ? { ...bodyPayload } : {};
@@ -369,12 +363,14 @@ export abstract class ToolSet {
369363
return tool;
370364
}
371365

372-
private buildActionHeaders(headers: Record<string, string>): Record<string, string> {
366+
private buildActionHeaders(headers: Record<string, string>): StackOneHeaders {
373367
const sanitizedEntries = Object.entries(headers).filter(
374368
([key]) => key.toLowerCase() !== 'authorization',
375369
);
376370

377-
return Object.fromEntries(sanitizedEntries.map(([key, value]) => [key, String(value)]));
371+
return stackOneHeadersSchema.parse(
372+
Object.fromEntries(sanitizedEntries.map(([key, value]) => [key, String(value)])),
373+
);
378374
}
379375

380376
private extractRecord(

src/utils/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+
});

0 commit comments

Comments
 (0)