@shipfox/api-integration-core 12.4.0 → 12.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-core",
3
3
  "license": "MIT",
4
- "version": "12.4.0",
4
+ "version": "12.5.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,17 +26,17 @@
26
26
  "zod": "^4.4.3",
27
27
  "@shipfox/api-agent-dto": "12.2.0",
28
28
  "@shipfox/api-auth-context": "12.2.0",
29
- "@shipfox/api-workflows-dto": "12.3.0",
29
+ "@shipfox/api-workflows-dto": "12.5.0",
30
30
  "@shipfox/api-integration-core-dto": "12.2.0",
31
- "@shipfox/api-integration-spi": "1.1.0",
31
+ "@shipfox/api-integration-spi": "1.1.1",
32
32
  "@shipfox/api-workspaces-dto": "12.0.0",
33
- "@shipfox/api-integration-gitea": "12.3.0",
34
- "@shipfox/api-integration-github": "12.3.0",
35
- "@shipfox/api-integration-jira": "12.4.0",
36
- "@shipfox/api-integration-linear": "12.3.0",
37
- "@shipfox/api-integration-sentry": "12.3.0",
38
- "@shipfox/api-integration-slack": "12.3.0",
39
- "@shipfox/api-integration-webhook": "12.3.0",
33
+ "@shipfox/api-integration-gitea": "12.5.0",
34
+ "@shipfox/api-integration-github": "12.5.0",
35
+ "@shipfox/api-integration-jira": "12.5.0",
36
+ "@shipfox/api-integration-linear": "12.5.0",
37
+ "@shipfox/api-integration-sentry": "12.5.0",
38
+ "@shipfox/api-integration-slack": "12.5.0",
39
+ "@shipfox/api-integration-webhook": "12.5.0",
40
40
  "@shipfox/config": "1.2.4",
41
41
  "@shipfox/inter-module": "0.2.3",
42
42
  "@shipfox/node-drizzle": "0.3.5",
@@ -9,6 +9,7 @@ import {
9
9
  } from '@shipfox/api-auth-context';
10
10
  import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
11
11
  import type {FastifyInstance, FastifyRequest} from 'fastify';
12
+ import {IntegrationProviderError} from '#core/errors.js';
12
13
  import {
13
14
  agentStepConfig,
14
15
  catalogTool,
@@ -227,13 +228,19 @@ describe('agent tools gateway route', () => {
227
228
  });
228
229
  });
229
230
 
230
- it('returns bounded MCP errors when provider dispatch fails', async () => {
231
+ it('returns the provider message and terminal code for rejected calls', async () => {
231
232
  const lease = leaseContext({workspaceId: 'workspace-1'});
232
233
  const integration = materializedIntegration({connectionId: 'connection-1'});
233
234
  leases.set('provider-error-lease', lease);
235
+ const providerError = new IntegrationProviderError(
236
+ 'provider-rejected',
237
+ 'commit_id is missing',
238
+ undefined,
239
+ 422,
240
+ );
234
241
  const app = await createGatewayApp({
235
242
  registry: registryWithAgentTools([catalogTool()], {
236
- callError: new Error('remote provider leaked implementation detail'),
243
+ callError: providerError,
237
244
  }),
238
245
  loadLeasedAgentStep: async () => ({
239
246
  workspaceId: lease.workspaceId,
@@ -269,8 +276,8 @@ describe('agent tools gateway route', () => {
269
276
 
270
277
  expect(result).toEqual({
271
278
  isError: true,
272
- content: [{type: 'text', text: 'Integration provider call failed'}],
273
- structuredContent: {code: 'provider-unavailable'},
279
+ content: [{type: 'text', text: 'commit_id is missing'}],
280
+ structuredContent: {code: 'provider-rejected', status: 422},
274
281
  });
275
282
  });
276
283
 
@@ -0,0 +1,107 @@
1
+ import type {reportError as reportErrorType} from '@shipfox/node-error-monitoring';
2
+ import type {logger as loggerFactoryType} from '@shipfox/node-opentelemetry';
3
+ import {IntegrationProviderError} from '#core/errors.js';
4
+ import {
5
+ catalogTool,
6
+ connection,
7
+ materializedIntegration,
8
+ materializedTool,
9
+ registryWithAgentTools,
10
+ } from '#test/agent-tools-gateway-helpers.js';
11
+ import {createIntegrationToolDispatcher} from './dispatch.js';
12
+ import type {AuthorizedIntegrationTool} from './resolve-authorized-tools.js';
13
+
14
+ const dispatchMocks = vi.hoisted(() => ({
15
+ loggerError: vi.fn(),
16
+ reportError: vi.fn(),
17
+ }));
18
+
19
+ const loggerFactory = (() => ({
20
+ error: dispatchMocks.loggerError,
21
+ })) as unknown as typeof loggerFactoryType;
22
+ const reportError = dispatchMocks.reportError as unknown as typeof reportErrorType;
23
+
24
+ describe('createIntegrationToolDispatcher', () => {
25
+ beforeEach(() => {
26
+ dispatchMocks.loggerError.mockReset();
27
+ dispatchMocks.reportError.mockReset();
28
+ });
29
+
30
+ it('preserves terminal provider errors without reporting them as outages', async () => {
31
+ const providerError = new IntegrationProviderError(
32
+ 'provider-rejected',
33
+ 'commit_id is missing',
34
+ undefined,
35
+ 422,
36
+ );
37
+ const dispatch = createDispatcher(providerError);
38
+
39
+ const result = await dispatch({
40
+ authorizedTool: authorizedTool(),
41
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
42
+ });
43
+
44
+ expect(result).toEqual({
45
+ isError: true,
46
+ content: [{type: 'text', text: 'commit_id is missing'}],
47
+ structuredContent: {code: 'provider-rejected', status: 422},
48
+ });
49
+ expect(dispatchMocks.loggerError).not.toHaveBeenCalled();
50
+ expect(dispatchMocks.reportError).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('reports provider outages while preserving their message and status', async () => {
54
+ const providerError = new IntegrationProviderError(
55
+ 'provider-unavailable',
56
+ 'GitHub returned HTTP 503',
57
+ undefined,
58
+ 503,
59
+ );
60
+ const dispatch = createDispatcher(providerError);
61
+
62
+ const result = await dispatch({
63
+ authorizedTool: authorizedTool(),
64
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
65
+ });
66
+
67
+ expect(result).toEqual({
68
+ isError: true,
69
+ content: [{type: 'text', text: 'GitHub returned HTTP 503'}],
70
+ structuredContent: {code: 'provider-unavailable', status: 503},
71
+ });
72
+ expect(dispatchMocks.loggerError).toHaveBeenCalledWith(
73
+ {err: providerError, provider: 'github'},
74
+ 'Integration agent tool provider was unavailable',
75
+ );
76
+ expect(dispatchMocks.reportError).toHaveBeenCalledWith(providerError, {
77
+ boundary: 'integration.agent-tool',
78
+ });
79
+ });
80
+ });
81
+
82
+ function createDispatcher(callError: IntegrationProviderError) {
83
+ return createIntegrationToolDispatcher(
84
+ {
85
+ registry: registryWithAgentTools([catalogTool()], {callError}),
86
+ },
87
+ {logger: loggerFactory, reportError},
88
+ );
89
+ }
90
+
91
+ function authorizedTool(): AuthorizedIntegrationTool {
92
+ const integration = materializedIntegration({connectionId: 'connection-1'});
93
+ const tool = materializedTool();
94
+ return {
95
+ mcpName: 'github_main__issue_read',
96
+ integration,
97
+ tool,
98
+ connection: connection({
99
+ id: integration.connectionId,
100
+ workspaceId: 'workspace-1',
101
+ slug: integration.connectionSlug,
102
+ }),
103
+ description: 'Read issue metadata from GitHub.',
104
+ inputSchema: tool.inputSchema,
105
+ outputSchema: tool.outputSchema,
106
+ };
107
+ }
@@ -15,17 +15,33 @@ export interface CreateIntegrationToolDispatcherParams {
15
15
  registry: IntegrationProviderRegistry;
16
16
  }
17
17
 
18
+ export interface IntegrationToolDispatcherDependencies {
19
+ logger?: typeof logger;
20
+ reportError?: typeof reportError;
21
+ }
22
+
18
23
  const timeoutErrorPattern = /timed?\s*out|timeout/i;
19
24
  const credentialErrorNamePattern = /Token|Credential|Secret|AccessToken/;
20
25
 
21
26
  export function createIntegrationToolDispatcher(
22
27
  params: CreateIntegrationToolDispatcherParams,
28
+ dependencies: IntegrationToolDispatcherDependencies = {},
23
29
  ): IntegrationToolDispatcher {
24
- return (input) => dispatchIntegrationToolCall({...input, registry: params.registry});
30
+ return (input) =>
31
+ dispatchIntegrationToolCall({
32
+ ...input,
33
+ registry: params.registry,
34
+ logger: dependencies.logger ?? logger,
35
+ reportError: dependencies.reportError ?? reportError,
36
+ });
25
37
  }
26
38
 
27
39
  async function dispatchIntegrationToolCall(
28
- input: IntegrationToolDispatchInput & {registry: IntegrationProviderRegistry},
40
+ input: IntegrationToolDispatchInput & {
41
+ registry: IntegrationProviderRegistry;
42
+ logger: typeof logger;
43
+ reportError: typeof reportError;
44
+ },
29
45
  ): Promise<CallToolResult> {
30
46
  let session: AgentToolSession<CallToolResult> | undefined;
31
47
 
@@ -52,15 +68,17 @@ async function dispatchIntegrationToolCall(
52
68
  } catch (error) {
53
69
  const result = errorResult(error);
54
70
  if (result.code === 'provider-unavailable') {
55
- logger().error(
56
- {err: error, provider: input.authorizedTool.integration.provider},
57
- 'Integration agent tool provider was unavailable',
58
- );
59
- reportError(error, {boundary: 'integration.agent-tool'});
71
+ input
72
+ .logger()
73
+ .error(
74
+ {err: error, provider: input.authorizedTool.integration.provider},
75
+ 'Integration agent tool provider was unavailable',
76
+ );
77
+ input.reportError(error, {boundary: 'integration.agent-tool'});
60
78
  }
61
79
  return toolError(result);
62
80
  } finally {
63
- await closeSession(session);
81
+ await closeSession(session, input.logger, input.reportError);
64
82
  }
65
83
  }
66
84
 
@@ -90,21 +108,36 @@ function agentToolCatalogEntry(input: IntegrationToolDispatchInput): AgentToolCa
90
108
  };
91
109
  }
92
110
 
93
- async function closeSession(session: {close?(): Promise<void>} | undefined): Promise<void> {
111
+ async function closeSession(
112
+ session: {close?(): Promise<void>} | undefined,
113
+ loggerFactory: typeof logger,
114
+ reportErrorFn: typeof reportError,
115
+ ): Promise<void> {
94
116
  try {
95
117
  await session?.close?.();
96
118
  } catch (error) {
97
119
  // Cleanup must not mask the tool result returned to the runner.
98
- logger().error({err: error}, 'Failed to close integration agent tool session');
99
- reportError(error, {boundary: 'integration.agent-tool', operation: 'close-session'});
120
+ loggerFactory().error({err: error}, 'Failed to close integration agent tool session');
121
+ reportErrorFn(error, {boundary: 'integration.agent-tool', operation: 'close-session'});
100
122
  }
101
123
  }
102
124
 
103
- function errorResult(error: unknown): {code: string; message: string} {
125
+ interface IntegrationToolError {
126
+ code: string;
127
+ message: string;
128
+ retryAfterSeconds?: number | undefined;
129
+ status?: number | undefined;
130
+ }
131
+
132
+ function errorResult(error: unknown): IntegrationToolError {
104
133
  if (error instanceof IntegrationProviderError) {
105
134
  return {
106
135
  code: error.reason,
107
- message: `Integration provider error: ${error.reason}`,
136
+ message: error.message,
137
+ ...(error.retryAfterSeconds === undefined
138
+ ? {}
139
+ : {retryAfterSeconds: error.retryAfterSeconds}),
140
+ ...(error.status === undefined ? {} : {status: error.status}),
108
141
  };
109
142
  }
110
143
 
@@ -142,10 +175,16 @@ function isCredentialError(error: unknown): boolean {
142
175
  return credentialErrorNamePattern.test(error.name);
143
176
  }
144
177
 
145
- function toolError(params: {code: string; message: string}): CallToolResult {
178
+ function toolError(params: IntegrationToolError): CallToolResult {
146
179
  return {
147
180
  isError: true,
148
181
  content: [{type: 'text', text: params.message}],
149
- structuredContent: {code: params.code},
182
+ structuredContent: {
183
+ code: params.code,
184
+ ...(params.retryAfterSeconds === undefined
185
+ ? {}
186
+ : {retryAfterSeconds: params.retryAfterSeconds}),
187
+ ...(params.status === undefined ? {} : {status: params.status}),
188
+ },
150
189
  };
151
190
  }
@@ -28,6 +28,7 @@ function isProviderError(error: unknown): error is IntegrationProviderError {
28
28
  error.reason === 'rate-limited' ||
29
29
  error.reason === 'timeout' ||
30
30
  error.reason === 'provider-unavailable' ||
31
+ error.reason === 'provider-rejected' ||
31
32
  error.reason === 'malformed-provider-response' ||
32
33
  error.reason === 'content-too-large' ||
33
34
  error.reason === 'too-many-files'))