@shipfox/api-integration-core 12.4.0 → 12.6.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.
Files changed (36) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +32 -0
  3. package/dist/metrics/instance.d.ts +5 -0
  4. package/dist/metrics/instance.d.ts.map +1 -1
  5. package/dist/metrics/instance.js +21 -1
  6. package/dist/metrics/instance.js.map +1 -1
  7. package/dist/presentation/routes/agent-tools-gateway/audit.d.ts +4 -1
  8. package/dist/presentation/routes/agent-tools-gateway/audit.d.ts.map +1 -1
  9. package/dist/presentation/routes/agent-tools-gateway/audit.js +6 -1
  10. package/dist/presentation/routes/agent-tools-gateway/audit.js.map +1 -1
  11. package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts +9 -1
  12. package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts.map +1 -1
  13. package/dist/presentation/routes/agent-tools-gateway/dispatch.js +55 -17
  14. package/dist/presentation/routes/agent-tools-gateway/dispatch.js.map +1 -1
  15. package/dist/presentation/routes/agent-tools-gateway/index.d.ts.map +1 -1
  16. package/dist/presentation/routes/agent-tools-gateway/index.js +6 -5
  17. package/dist/presentation/routes/agent-tools-gateway/index.js.map +1 -1
  18. package/dist/presentation/routes/agent-tools-gateway/mcp-server.d.ts.map +1 -1
  19. package/dist/presentation/routes/agent-tools-gateway/mcp-server.js +27 -5
  20. package/dist/presentation/routes/agent-tools-gateway/mcp-server.js.map +1 -1
  21. package/dist/presentation/routes/errors.d.ts.map +1 -1
  22. package/dist/presentation/routes/errors.js +1 -1
  23. package/dist/presentation/routes/errors.js.map +1 -1
  24. package/dist/tsconfig.test.tsbuildinfo +1 -1
  25. package/package.json +10 -10
  26. package/src/metrics/instance.ts +40 -1
  27. package/src/presentation/routes/agent-tools-gateway/agent-tools-gateway.route.test.ts +11 -4
  28. package/src/presentation/routes/agent-tools-gateway/audit.test.ts +51 -0
  29. package/src/presentation/routes/agent-tools-gateway/audit.ts +8 -0
  30. package/src/presentation/routes/agent-tools-gateway/dispatch.test.ts +167 -0
  31. package/src/presentation/routes/agent-tools-gateway/dispatch.ts +93 -20
  32. package/src/presentation/routes/agent-tools-gateway/index.ts +3 -4
  33. package/src/presentation/routes/agent-tools-gateway/mcp-server.test.ts +41 -4
  34. package/src/presentation/routes/agent-tools-gateway/mcp-server.ts +35 -1
  35. package/src/presentation/routes/errors.ts +1 -0
  36. package/tsconfig.build.tsbuildinfo +1 -1
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.6.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.6.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.6.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",
@@ -1,3 +1,4 @@
1
+ import type {IntegrationProviderErrorReason} from '@shipfox/api-integration-spi';
1
2
  import {instanceMetrics} from '@shipfox/node-opentelemetry';
2
3
 
3
4
  const meter = instanceMetrics.getMeter('integrations');
@@ -8,13 +9,42 @@ export type IntegrationAgentToolCallOutcome =
8
9
  | 'invalid-request'
9
10
  | 'exception';
10
11
 
12
+ export type IntegrationAgentToolCallErrorCode =
13
+ | 'invalid-request'
14
+ | 'unknown'
15
+ | 'provider-timeout'
16
+ | 'credentials-unavailable'
17
+ | IntegrationProviderErrorReason;
18
+
19
+ export type IntegrationAgentToolCallErrorLabel = IntegrationAgentToolCallErrorCode | 'none';
20
+
21
+ const integrationAgentToolCallErrorCodes = new Set<string>([
22
+ 'invalid-request',
23
+ 'unknown',
24
+ 'provider-timeout',
25
+ 'credentials-unavailable',
26
+ 'repository-not-found',
27
+ 'installation-not-found',
28
+ 'file-not-found',
29
+ 'access-denied',
30
+ 'rate-limited',
31
+ 'timeout',
32
+ 'provider-unavailable',
33
+ 'provider-rejected',
34
+ 'malformed-provider-response',
35
+ 'content-too-large',
36
+ 'too-many-files',
37
+ ]);
38
+
11
39
  const agentToolCallCount = meter.createCounter<{
12
40
  provider: string;
13
41
  tool: string;
14
42
  method: string;
15
43
  outcome: IntegrationAgentToolCallOutcome;
44
+ error_code: IntegrationAgentToolCallErrorLabel;
16
45
  }>('integrations_agent_tool_call', {
17
- description: 'Integration agent tool calls by provider, tool, method, and outcome',
46
+ description:
47
+ 'Integration agent tool calls by provider, tool, method, outcome, and bounded error code',
18
48
  });
19
49
 
20
50
  function recordMetric(record: () => void): void {
@@ -30,6 +60,15 @@ export function recordIntegrationAgentToolCall(params: {
30
60
  tool: string;
31
61
  method: string;
32
62
  outcome: IntegrationAgentToolCallOutcome;
63
+ error_code: IntegrationAgentToolCallErrorLabel;
33
64
  }): void {
34
65
  recordMetric(() => agentToolCallCount.add(1, params));
35
66
  }
67
+
68
+ export function normalizeIntegrationAgentToolCallErrorCode(
69
+ value: unknown,
70
+ ): IntegrationAgentToolCallErrorCode {
71
+ return typeof value === 'string' && integrationAgentToolCallErrorCodes.has(value)
72
+ ? (value as IntegrationAgentToolCallErrorCode)
73
+ : 'unknown';
74
+ }
@@ -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
 
@@ -43,6 +43,7 @@ describe('integration tool call audit', () => {
43
43
  },
44
44
  method: 'get',
45
45
  outcome: 'success',
46
+ errorCode: 'none',
46
47
  });
47
48
 
48
49
  expect(recordMetric).toHaveBeenCalledWith({
@@ -50,6 +51,7 @@ describe('integration tool call audit', () => {
50
51
  tool: 'issue_read',
51
52
  method: 'get',
52
53
  outcome: 'success',
54
+ error_code: 'none',
53
55
  });
54
56
  expect(logInfo).toHaveBeenCalledWith(
55
57
  expect.objectContaining({
@@ -65,6 +67,7 @@ describe('integration tool call audit', () => {
65
67
  toolId: 'issue_read',
66
68
  method: 'get',
67
69
  outcome: 'success',
70
+ errorCode: 'none',
68
71
  argumentSummary: {
69
72
  keys: ['owner', 'repo', 'token'],
70
73
  serializedSizeBytes: expect.any(Number),
@@ -75,6 +78,54 @@ describe('integration tool call audit', () => {
75
78
  expect(JSON.stringify(logInfo.mock.calls)).not.toContain('must-not-appear');
76
79
  });
77
80
 
81
+ it('records bounded provider error details without logging arguments', () => {
82
+ const recordMetric = vi.fn();
83
+ const logInfo = vi.fn();
84
+ const lease = leaseContext({jobId: 'job-1', workspaceId: 'workspace-1'});
85
+ const integration = materializedIntegration({connectionId: 'connection-1'});
86
+ const tool = materializedTool();
87
+ const recorder = createIntegrationToolCallRecorder(lease, {recordMetric, logInfo});
88
+
89
+ recorder({
90
+ authorizedTool: {
91
+ mcpName: 'github_main__issue_read',
92
+ integration,
93
+ tool,
94
+ connection: connection({
95
+ id: 'connection-1',
96
+ workspaceId: 'workspace-1',
97
+ slug: integration.connectionSlug,
98
+ }),
99
+ description: 'Read issues',
100
+ inputSchema: tool.inputSchema,
101
+ },
102
+ arguments: {repo: 'private-repository', token: 'must-not-appear'},
103
+ method: 'get',
104
+ outcome: 'tool-error',
105
+ errorCode: 'provider-rejected',
106
+ providerStatus: 422,
107
+ });
108
+
109
+ expect(recordMetric).toHaveBeenCalledWith({
110
+ provider: 'github',
111
+ tool: 'issue_read',
112
+ method: 'get',
113
+ outcome: 'tool-error',
114
+ error_code: 'provider-rejected',
115
+ });
116
+ expect(logInfo).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ jobId: 'job-1',
119
+ workspaceId: 'workspace-1',
120
+ errorCode: 'provider-rejected',
121
+ providerStatus: 422,
122
+ }),
123
+ 'integration tool call audited',
124
+ );
125
+ expect(JSON.stringify(logInfo.mock.calls)).not.toContain('private-repository');
126
+ expect(JSON.stringify(logInfo.mock.calls)).not.toContain('must-not-appear');
127
+ });
128
+
78
129
  it('summarizes arguments without values', () => {
79
130
  const summary = summarizeIntegrationToolArguments({z: 'secret', a: 1});
80
131
 
@@ -1,11 +1,14 @@
1
1
  import type {LeasedJobContext} from '@shipfox/api-auth-context';
2
2
  import {logger} from '@shipfox/node-opentelemetry';
3
3
  import {
4
+ type IntegrationAgentToolCallErrorLabel,
4
5
  type IntegrationAgentToolCallOutcome,
5
6
  recordIntegrationAgentToolCall,
6
7
  } from '#metrics/index.js';
7
8
  import type {AuthorizedIntegrationTool} from './resolve-authorized-tools.js';
8
9
 
10
+ export type {IntegrationAgentToolCallErrorCode} from '#metrics/index.js';
11
+
9
12
  export const UNKNOWN_TOOL_LABEL = 'unknown';
10
13
  export const NO_METHOD_LABEL = 'none';
11
14
  export const INVALID_METHOD_LABEL = 'invalid';
@@ -20,6 +23,8 @@ export interface IntegrationToolCallAuditRecord {
20
23
  arguments: unknown;
21
24
  method: string;
22
25
  outcome: IntegrationAgentToolCallOutcome;
26
+ errorCode: IntegrationAgentToolCallErrorLabel;
27
+ providerStatus?: number | undefined;
23
28
  }
24
29
 
25
30
  export type IntegrationToolCallRecorder = (record: IntegrationToolCallAuditRecord) => void;
@@ -47,6 +52,7 @@ export function createIntegrationToolCallRecorder(
47
52
  tool: toolId,
48
53
  method: record.method,
49
54
  outcome: record.outcome,
55
+ error_code: record.errorCode,
50
56
  });
51
57
 
52
58
  logInfo(
@@ -63,6 +69,8 @@ export function createIntegrationToolCallRecorder(
63
69
  toolId,
64
70
  method: record.method,
65
71
  outcome: record.outcome,
72
+ errorCode: record.errorCode,
73
+ ...(record.providerStatus === undefined ? {} : {providerStatus: record.providerStatus}),
66
74
  argumentSummary: summarizeIntegrationToolArguments(record.arguments),
67
75
  },
68
76
  'integration tool call audited',
@@ -0,0 +1,167 @@
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
+ leaseContext,
8
+ materializedIntegration,
9
+ materializedTool,
10
+ registryWithAgentTools,
11
+ } from '#test/agent-tools-gateway-helpers.js';
12
+ import {createIntegrationToolDispatcher} from './dispatch.js';
13
+ import type {AuthorizedIntegrationTool} from './resolve-authorized-tools.js';
14
+
15
+ const dispatchMocks = vi.hoisted(() => ({
16
+ loggerError: vi.fn(),
17
+ reportError: vi.fn(),
18
+ }));
19
+
20
+ const loggerFactory = (() => ({
21
+ error: dispatchMocks.loggerError,
22
+ })) as unknown as typeof loggerFactoryType;
23
+ const reportError = dispatchMocks.reportError as unknown as typeof reportErrorType;
24
+
25
+ describe('createIntegrationToolDispatcher', () => {
26
+ beforeEach(() => {
27
+ dispatchMocks.loggerError.mockReset();
28
+ dispatchMocks.reportError.mockReset();
29
+ });
30
+
31
+ it('preserves terminal provider errors without reporting them as outages', async () => {
32
+ const providerError = new IntegrationProviderError(
33
+ 'provider-rejected',
34
+ 'commit_id is missing',
35
+ undefined,
36
+ 422,
37
+ );
38
+ const dispatch = createDispatcher(providerError);
39
+
40
+ const result = await dispatch({
41
+ authorizedTool: authorizedTool(),
42
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
43
+ method: 'get',
44
+ });
45
+
46
+ expect(result).toEqual({
47
+ isError: true,
48
+ content: [{type: 'text', text: 'commit_id is missing'}],
49
+ structuredContent: {code: 'provider-rejected', status: 422},
50
+ });
51
+ expect(dispatchMocks.loggerError).not.toHaveBeenCalled();
52
+ expect(dispatchMocks.reportError).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it('reports provider outages while preserving their message and status', async () => {
56
+ const providerError = new IntegrationProviderError(
57
+ 'provider-unavailable',
58
+ 'GitHub returned HTTP 503',
59
+ undefined,
60
+ 503,
61
+ );
62
+ const dispatch = createDispatcher(providerError);
63
+
64
+ const result = await dispatch({
65
+ authorizedTool: authorizedTool(),
66
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
67
+ method: 'get',
68
+ });
69
+
70
+ expect(result).toEqual({
71
+ isError: true,
72
+ content: [{type: 'text', text: 'GitHub returned HTTP 503'}],
73
+ structuredContent: {code: 'provider-unavailable', status: 503},
74
+ });
75
+ expect(dispatchMocks.loggerError).toHaveBeenCalledWith(
76
+ expect.objectContaining({
77
+ err: providerError,
78
+ provider: 'github',
79
+ toolId: 'issue_read',
80
+ method: 'get',
81
+ errorCode: 'provider-unavailable',
82
+ providerStatus: 503,
83
+ }),
84
+ 'Integration agent tool provider was unavailable',
85
+ );
86
+ expect(dispatchMocks.loggerError.mock.calls[0]?.[0]).toMatchObject({
87
+ jobId: 'job-1',
88
+ jobExecutionId: 'execution-1',
89
+ workflowRunId: 'run-1',
90
+ workflowRunAttemptId: 'attempt-1',
91
+ workspaceId: 'workspace-1',
92
+ currentStepId: 'step-1',
93
+ currentStepAttempt: 2,
94
+ connectionId: 'connection-1',
95
+ });
96
+ expect(dispatchMocks.reportError).toHaveBeenCalledWith(providerError, {
97
+ boundary: 'integration.agent-tool',
98
+ });
99
+ });
100
+
101
+ it('classifies unrecognized failures as unknown instead of provider outages', async () => {
102
+ const internalError = new Error('request timeout configuration is invalid');
103
+ const dispatch = createDispatcher(internalError);
104
+
105
+ const result = await dispatch({
106
+ authorizedTool: authorizedTool(),
107
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
108
+ method: 'get',
109
+ });
110
+
111
+ expect(result).toEqual({
112
+ isError: true,
113
+ content: [{type: 'text', text: 'Integration tool call failed'}],
114
+ structuredContent: {code: 'unknown'},
115
+ });
116
+ expect(dispatchMocks.loggerError).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ err: internalError,
119
+ provider: 'github',
120
+ toolId: 'issue_read',
121
+ method: 'get',
122
+ errorCode: 'unknown',
123
+ }),
124
+ 'Integration agent tool call failed',
125
+ );
126
+ expect(dispatchMocks.loggerError.mock.calls[0]?.[0]).not.toHaveProperty('providerStatus');
127
+ expect(dispatchMocks.reportError).toHaveBeenCalledWith(internalError, {
128
+ boundary: 'integration.agent-tool',
129
+ });
130
+ });
131
+ });
132
+
133
+ function createDispatcher(callError: unknown) {
134
+ return createIntegrationToolDispatcher(
135
+ {
136
+ registry: registryWithAgentTools([catalogTool()], {callError}),
137
+ lease: leaseContext({
138
+ jobId: 'job-1',
139
+ jobExecutionId: 'execution-1',
140
+ workflowRunId: 'run-1',
141
+ workflowRunAttemptId: 'attempt-1',
142
+ workspaceId: 'workspace-1',
143
+ currentStepId: 'step-1',
144
+ currentStepAttempt: 2,
145
+ }),
146
+ },
147
+ {logger: loggerFactory, reportError},
148
+ );
149
+ }
150
+
151
+ function authorizedTool(): AuthorizedIntegrationTool {
152
+ const integration = materializedIntegration({connectionId: 'connection-1'});
153
+ const tool = materializedTool();
154
+ return {
155
+ mcpName: 'github_main__issue_read',
156
+ integration,
157
+ tool,
158
+ connection: connection({
159
+ id: integration.connectionId,
160
+ workspaceId: 'workspace-1',
161
+ slug: integration.connectionSlug,
162
+ }),
163
+ description: 'Read issue metadata from GitHub.',
164
+ inputSchema: tool.inputSchema,
165
+ outputSchema: tool.outputSchema,
166
+ };
167
+ }
@@ -1,4 +1,5 @@
1
1
  import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
2
+ import type {LeasedJobContext} from '@shipfox/api-auth-context';
2
3
  import {reportError} from '@shipfox/node-error-monitoring';
3
4
  import {logger} from '@shipfox/node-opentelemetry';
4
5
  import {IntegrationProviderError} from '#core/errors.js';
@@ -9,23 +10,45 @@ import type {
9
10
  AgentToolsProvider,
10
11
  } from '#core/providers/agent-tools.js';
11
12
  import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
13
+ import type {IntegrationAgentToolCallErrorCode} from '#metrics/index.js';
14
+ import {NO_METHOD_LABEL} from './audit.js';
12
15
  import type {IntegrationToolDispatcher, IntegrationToolDispatchInput} from './mcp-server.js';
13
16
 
14
17
  export interface CreateIntegrationToolDispatcherParams {
15
18
  registry: IntegrationProviderRegistry;
19
+ lease?: LeasedJobContext | undefined;
16
20
  }
17
21
 
18
- const timeoutErrorPattern = /timed?\s*out|timeout/i;
22
+ export interface IntegrationToolDispatcherDependencies {
23
+ logger?: typeof logger;
24
+ reportError?: typeof reportError;
25
+ }
26
+
27
+ const timeoutErrorNamePattern = /timed?\s*out|timeout/i;
28
+ const mcpRequestTimeoutMessagePattern = /^MCP error -32001:\s*Request timed out\b/i;
19
29
  const credentialErrorNamePattern = /Token|Credential|Secret|AccessToken/;
20
30
 
21
31
  export function createIntegrationToolDispatcher(
22
32
  params: CreateIntegrationToolDispatcherParams,
33
+ dependencies: IntegrationToolDispatcherDependencies = {},
23
34
  ): IntegrationToolDispatcher {
24
- return (input) => dispatchIntegrationToolCall({...input, registry: params.registry});
35
+ return (input) =>
36
+ dispatchIntegrationToolCall({
37
+ ...input,
38
+ registry: params.registry,
39
+ lease: params.lease,
40
+ logger: dependencies.logger ?? logger,
41
+ reportError: dependencies.reportError ?? reportError,
42
+ });
25
43
  }
26
44
 
27
45
  async function dispatchIntegrationToolCall(
28
- input: IntegrationToolDispatchInput & {registry: IntegrationProviderRegistry},
46
+ input: IntegrationToolDispatchInput & {
47
+ registry: IntegrationProviderRegistry;
48
+ lease?: LeasedJobContext | undefined;
49
+ logger: typeof logger;
50
+ reportError: typeof reportError;
51
+ },
29
52
  ): Promise<CallToolResult> {
30
53
  let session: AgentToolSession<CallToolResult> | undefined;
31
54
 
@@ -51,19 +74,48 @@ async function dispatchIntegrationToolCall(
51
74
  });
52
75
  } catch (error) {
53
76
  const result = errorResult(error);
54
- if (result.code === 'provider-unavailable') {
55
- logger().error(
56
- {err: error, provider: input.authorizedTool.integration.provider},
57
- 'Integration agent tool provider was unavailable',
77
+ if (result.code === 'provider-unavailable' || result.code === 'unknown') {
78
+ input.logger().error(
79
+ {
80
+ ...toolCallLogContext(input),
81
+ err: error,
82
+ errorCode: result.code,
83
+ ...(result.status === undefined ? {} : {providerStatus: result.status}),
84
+ },
85
+ result.code === 'provider-unavailable'
86
+ ? 'Integration agent tool provider was unavailable'
87
+ : 'Integration agent tool call failed',
58
88
  );
59
- reportError(error, {boundary: 'integration.agent-tool'});
89
+ input.reportError(error, {boundary: 'integration.agent-tool'});
60
90
  }
61
91
  return toolError(result);
62
92
  } finally {
63
- await closeSession(session);
93
+ await closeSession(session, input.logger, input.reportError);
64
94
  }
65
95
  }
66
96
 
97
+ function toolCallLogContext(
98
+ input: IntegrationToolDispatchInput & {lease?: LeasedJobContext | undefined},
99
+ ): Record<string, unknown> {
100
+ return {
101
+ ...(input.lease === undefined
102
+ ? {}
103
+ : {
104
+ jobId: input.lease.jobId,
105
+ jobExecutionId: input.lease.jobExecutionId,
106
+ workflowRunId: input.lease.workflowRunId,
107
+ workflowRunAttemptId: input.lease.workflowRunAttemptId,
108
+ workspaceId: input.lease.workspaceId,
109
+ currentStepId: input.lease.currentStepId,
110
+ currentStepAttempt: input.lease.currentStepAttempt,
111
+ }),
112
+ connectionId: input.authorizedTool.connection.id,
113
+ provider: input.authorizedTool.integration.provider,
114
+ toolId: input.authorizedTool.tool.id,
115
+ method: input.method ?? NO_METHOD_LABEL,
116
+ };
117
+ }
118
+
67
119
  function agentToolCatalogEntry(input: IntegrationToolDispatchInput): AgentToolCatalogEntry {
68
120
  const {tool, description, inputSchema, outputSchema} = input.authorizedTool;
69
121
  return {
@@ -90,21 +142,36 @@ function agentToolCatalogEntry(input: IntegrationToolDispatchInput): AgentToolCa
90
142
  };
91
143
  }
92
144
 
93
- async function closeSession(session: {close?(): Promise<void>} | undefined): Promise<void> {
145
+ async function closeSession(
146
+ session: {close?(): Promise<void>} | undefined,
147
+ loggerFactory: typeof logger,
148
+ reportErrorFn: typeof reportError,
149
+ ): Promise<void> {
94
150
  try {
95
151
  await session?.close?.();
96
152
  } catch (error) {
97
153
  // 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'});
154
+ loggerFactory().error({err: error}, 'Failed to close integration agent tool session');
155
+ reportErrorFn(error, {boundary: 'integration.agent-tool', operation: 'close-session'});
100
156
  }
101
157
  }
102
158
 
103
- function errorResult(error: unknown): {code: string; message: string} {
159
+ interface IntegrationToolError {
160
+ code: IntegrationAgentToolCallErrorCode;
161
+ message: string;
162
+ retryAfterSeconds?: number | undefined;
163
+ status?: number | undefined;
164
+ }
165
+
166
+ function errorResult(error: unknown): IntegrationToolError {
104
167
  if (error instanceof IntegrationProviderError) {
105
168
  return {
106
169
  code: error.reason,
107
- message: `Integration provider error: ${error.reason}`,
170
+ message: error.message,
171
+ ...(error.retryAfterSeconds === undefined
172
+ ? {}
173
+ : {retryAfterSeconds: error.retryAfterSeconds}),
174
+ ...(error.status === undefined ? {} : {status: error.status}),
108
175
  };
109
176
  }
110
177
 
@@ -123,8 +190,8 @@ function errorResult(error: unknown): {code: string; message: string} {
123
190
  }
124
191
 
125
192
  return {
126
- code: 'provider-unavailable',
127
- message: 'Integration provider call failed',
193
+ code: 'unknown',
194
+ message: 'Integration tool call failed',
128
195
  };
129
196
  }
130
197
 
@@ -132,8 +199,8 @@ function isTimeoutError(error: unknown): boolean {
132
199
  if (!(error instanceof Error)) return false;
133
200
  return (
134
201
  error.name === 'AbortError' ||
135
- timeoutErrorPattern.test(error.name) ||
136
- timeoutErrorPattern.test(error.message)
202
+ timeoutErrorNamePattern.test(error.name) ||
203
+ (error.name === 'McpError' && mcpRequestTimeoutMessagePattern.test(error.message))
137
204
  );
138
205
  }
139
206
 
@@ -142,10 +209,16 @@ function isCredentialError(error: unknown): boolean {
142
209
  return credentialErrorNamePattern.test(error.name);
143
210
  }
144
211
 
145
- function toolError(params: {code: string; message: string}): CallToolResult {
212
+ function toolError(params: IntegrationToolError): CallToolResult {
146
213
  return {
147
214
  isError: true,
148
215
  content: [{type: 'text', text: params.message}],
149
- structuredContent: {code: params.code},
216
+ structuredContent: {
217
+ code: params.code,
218
+ ...(params.retryAfterSeconds === undefined
219
+ ? {}
220
+ : {retryAfterSeconds: params.retryAfterSeconds}),
221
+ ...(params.status === undefined ? {} : {status: params.status}),
222
+ },
150
223
  };
151
224
  }
@@ -26,8 +26,6 @@ export interface CreateAgentToolsGatewayRoutesParams {
26
26
  export function createAgentToolsGatewayRoutes(
27
27
  params: CreateAgentToolsGatewayRoutesParams,
28
28
  ): RouteGroup {
29
- const dispatchIntegrationToolCall = createIntegrationToolDispatcher({registry: params.registry});
30
-
31
29
  return {
32
30
  prefix: '/runs/jobs/current/integration-tools',
33
31
  auth: AUTH_LEASED_JOB,
@@ -37,6 +35,7 @@ export function createAgentToolsGatewayRoutes(
37
35
  path: '/mcp',
38
36
  description: 'Gateway MCP endpoint for integration-backed agent tools',
39
37
  handler: async (request, reply) => {
38
+ const lease = requireLeasedJobContext(request);
40
39
  const authorizedTools = await resolveAuthorizedIntegrationTools({
41
40
  request,
42
41
  loadLeasedAgentStep: params.loadLeasedAgentStep,
@@ -45,8 +44,8 @@ export function createAgentToolsGatewayRoutes(
45
44
  });
46
45
  const server = buildAgentToolsMcpServer({
47
46
  authorizedTools,
48
- dispatch: dispatchIntegrationToolCall,
49
- recordCall: createIntegrationToolCallRecorder(requireLeasedJobContext(request)),
47
+ dispatch: createIntegrationToolDispatcher({registry: params.registry, lease}),
48
+ recordCall: createIntegrationToolCallRecorder(lease),
50
49
  });
51
50
  const transport = new StreamableHTTPServerTransport();
52
51