@shipfox/api-integration-core 12.5.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 (31) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +12 -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 +2 -0
  12. package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts.map +1 -1
  13. package/dist/presentation/routes/agent-tools-gateway/dispatch.js +31 -7
  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/tsconfig.test.tsbuildinfo +1 -1
  22. package/package.json +3 -3
  23. package/src/metrics/instance.ts +40 -1
  24. package/src/presentation/routes/agent-tools-gateway/audit.test.ts +51 -0
  25. package/src/presentation/routes/agent-tools-gateway/audit.ts +8 -0
  26. package/src/presentation/routes/agent-tools-gateway/dispatch.test.ts +62 -2
  27. package/src/presentation/routes/agent-tools-gateway/dispatch.ts +47 -13
  28. package/src/presentation/routes/agent-tools-gateway/index.ts +3 -4
  29. package/src/presentation/routes/agent-tools-gateway/mcp-server.test.ts +41 -4
  30. package/src/presentation/routes/agent-tools-gateway/mcp-server.ts +35 -1
  31. 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.5.0",
4
+ "version": "12.6.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,12 +26,12 @@
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.5.0",
29
+ "@shipfox/api-workflows-dto": "12.6.0",
30
30
  "@shipfox/api-integration-core-dto": "12.2.0",
31
31
  "@shipfox/api-integration-spi": "1.1.1",
32
32
  "@shipfox/api-workspaces-dto": "12.0.0",
33
33
  "@shipfox/api-integration-gitea": "12.5.0",
34
- "@shipfox/api-integration-github": "12.5.0",
34
+ "@shipfox/api-integration-github": "12.6.0",
35
35
  "@shipfox/api-integration-jira": "12.5.0",
36
36
  "@shipfox/api-integration-linear": "12.5.0",
37
37
  "@shipfox/api-integration-sentry": "12.5.0",
@@ -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
+ }
@@ -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',
@@ -4,6 +4,7 @@ import {IntegrationProviderError} from '#core/errors.js';
4
4
  import {
5
5
  catalogTool,
6
6
  connection,
7
+ leaseContext,
7
8
  materializedIntegration,
8
9
  materializedTool,
9
10
  registryWithAgentTools,
@@ -39,6 +40,7 @@ describe('createIntegrationToolDispatcher', () => {
39
40
  const result = await dispatch({
40
41
  authorizedTool: authorizedTool(),
41
42
  arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
43
+ method: 'get',
42
44
  });
43
45
 
44
46
  expect(result).toEqual({
@@ -62,6 +64,7 @@ describe('createIntegrationToolDispatcher', () => {
62
64
  const result = await dispatch({
63
65
  authorizedTool: authorizedTool(),
64
66
  arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
67
+ method: 'get',
65
68
  });
66
69
 
67
70
  expect(result).toEqual({
@@ -70,19 +73,76 @@ describe('createIntegrationToolDispatcher', () => {
70
73
  structuredContent: {code: 'provider-unavailable', status: 503},
71
74
  });
72
75
  expect(dispatchMocks.loggerError).toHaveBeenCalledWith(
73
- {err: providerError, provider: 'github'},
76
+ expect.objectContaining({
77
+ err: providerError,
78
+ provider: 'github',
79
+ toolId: 'issue_read',
80
+ method: 'get',
81
+ errorCode: 'provider-unavailable',
82
+ providerStatus: 503,
83
+ }),
74
84
  'Integration agent tool provider was unavailable',
75
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
+ });
76
96
  expect(dispatchMocks.reportError).toHaveBeenCalledWith(providerError, {
77
97
  boundary: 'integration.agent-tool',
78
98
  });
79
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
+ });
80
131
  });
81
132
 
82
- function createDispatcher(callError: IntegrationProviderError) {
133
+ function createDispatcher(callError: unknown) {
83
134
  return createIntegrationToolDispatcher(
84
135
  {
85
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
+ }),
86
146
  },
87
147
  {logger: loggerFactory, reportError},
88
148
  );
@@ -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,10 +10,13 @@ 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
22
  export interface IntegrationToolDispatcherDependencies {
@@ -20,7 +24,8 @@ export interface IntegrationToolDispatcherDependencies {
20
24
  reportError?: typeof reportError;
21
25
  }
22
26
 
23
- const timeoutErrorPattern = /timed?\s*out|timeout/i;
27
+ const timeoutErrorNamePattern = /timed?\s*out|timeout/i;
28
+ const mcpRequestTimeoutMessagePattern = /^MCP error -32001:\s*Request timed out\b/i;
24
29
  const credentialErrorNamePattern = /Token|Credential|Secret|AccessToken/;
25
30
 
26
31
  export function createIntegrationToolDispatcher(
@@ -31,6 +36,7 @@ export function createIntegrationToolDispatcher(
31
36
  dispatchIntegrationToolCall({
32
37
  ...input,
33
38
  registry: params.registry,
39
+ lease: params.lease,
34
40
  logger: dependencies.logger ?? logger,
35
41
  reportError: dependencies.reportError ?? reportError,
36
42
  });
@@ -39,6 +45,7 @@ export function createIntegrationToolDispatcher(
39
45
  async function dispatchIntegrationToolCall(
40
46
  input: IntegrationToolDispatchInput & {
41
47
  registry: IntegrationProviderRegistry;
48
+ lease?: LeasedJobContext | undefined;
42
49
  logger: typeof logger;
43
50
  reportError: typeof reportError;
44
51
  },
@@ -67,13 +74,18 @@ async function dispatchIntegrationToolCall(
67
74
  });
68
75
  } catch (error) {
69
76
  const result = errorResult(error);
70
- if (result.code === 'provider-unavailable') {
71
- input
72
- .logger()
73
- .error(
74
- {err: error, provider: input.authorizedTool.integration.provider},
75
- 'Integration agent tool provider was unavailable',
76
- );
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',
88
+ );
77
89
  input.reportError(error, {boundary: 'integration.agent-tool'});
78
90
  }
79
91
  return toolError(result);
@@ -82,6 +94,28 @@ async function dispatchIntegrationToolCall(
82
94
  }
83
95
  }
84
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
+
85
119
  function agentToolCatalogEntry(input: IntegrationToolDispatchInput): AgentToolCatalogEntry {
86
120
  const {tool, description, inputSchema, outputSchema} = input.authorizedTool;
87
121
  return {
@@ -123,7 +157,7 @@ async function closeSession(
123
157
  }
124
158
 
125
159
  interface IntegrationToolError {
126
- code: string;
160
+ code: IntegrationAgentToolCallErrorCode;
127
161
  message: string;
128
162
  retryAfterSeconds?: number | undefined;
129
163
  status?: number | undefined;
@@ -156,8 +190,8 @@ function errorResult(error: unknown): IntegrationToolError {
156
190
  }
157
191
 
158
192
  return {
159
- code: 'provider-unavailable',
160
- message: 'Integration provider call failed',
193
+ code: 'unknown',
194
+ message: 'Integration tool call failed',
161
195
  };
162
196
  }
163
197
 
@@ -165,8 +199,8 @@ function isTimeoutError(error: unknown): boolean {
165
199
  if (!(error instanceof Error)) return false;
166
200
  return (
167
201
  error.name === 'AbortError' ||
168
- timeoutErrorPattern.test(error.name) ||
169
- timeoutErrorPattern.test(error.message)
202
+ timeoutErrorNamePattern.test(error.name) ||
203
+ (error.name === 'McpError' && mcpRequestTimeoutMessagePattern.test(error.message))
170
204
  );
171
205
  }
172
206
 
@@ -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
 
@@ -54,6 +54,7 @@ describe('buildAgentToolsMcpServer', () => {
54
54
  }),
55
55
  method: 'get',
56
56
  outcome: 'success',
57
+ errorCode: 'none',
57
58
  },
58
59
  ]);
59
60
  });
@@ -93,6 +94,7 @@ describe('buildAgentToolsMcpServer', () => {
93
94
  {
94
95
  method: expectedMethod,
95
96
  outcome: 'invalid-request',
97
+ errorCode: 'invalid-request',
96
98
  },
97
99
  ]);
98
100
  if (expectedToolId) {
@@ -128,7 +130,9 @@ describe('buildAgentToolsMcpServer', () => {
128
130
  arguments: {method: 'ignored'},
129
131
  }),
130
132
  );
131
- expect(records).toMatchObject([{method: NO_METHOD_LABEL, outcome: 'success'}]);
133
+ expect(records).toMatchObject([
134
+ {method: NO_METHOD_LABEL, outcome: 'success', errorCode: 'none'},
135
+ ]);
132
136
  });
133
137
 
134
138
  it('defaults omitted arguments to an empty object for optional-argument tools', async () => {
@@ -179,7 +183,7 @@ describe('buildAgentToolsMcpServer', () => {
179
183
  arguments: expect.objectContaining({issue_number: 'not-an-integer'}),
180
184
  }),
181
185
  );
182
- expect(records).toMatchObject([{method: 'get', outcome: 'success'}]);
186
+ expect(records).toMatchObject([{method: 'get', outcome: 'success', errorCode: 'none'}]);
183
187
  });
184
188
 
185
189
  it('records tool-error when dispatch returns an error result', async () => {
@@ -204,7 +208,7 @@ describe('buildAgentToolsMcpServer', () => {
204
208
  await close();
205
209
 
206
210
  expect(result.isError).toBe(true);
207
- expect(records).toMatchObject([{method: 'get', outcome: 'tool-error'}]);
211
+ expect(records).toMatchObject([{method: 'get', outcome: 'tool-error', errorCode: 'unknown'}]);
208
212
  });
209
213
 
210
214
  it('records exception before rethrowing dispatcher failures', async () => {
@@ -227,7 +231,40 @@ describe('buildAgentToolsMcpServer', () => {
227
231
  ).rejects.toThrow('MCP error -32603');
228
232
  await close();
229
233
 
230
- expect(records).toMatchObject([{method: 'get', outcome: 'exception'}]);
234
+ expect(records).toMatchObject([{method: 'get', outcome: 'exception', errorCode: 'unknown'}]);
235
+ });
236
+
237
+ it('records bounded provider error details returned by the dispatcher', async () => {
238
+ const dispatch = vi.fn(async () => ({
239
+ isError: true,
240
+ content: [{type: 'text' as const, text: 'provider rejected call'}],
241
+ structuredContent: {code: 'provider-rejected', status: 422},
242
+ }));
243
+ const records: Parameters<
244
+ NonNullable<Parameters<typeof buildAgentToolsMcpServer>[0]['recordCall']>
245
+ >[0][] = [];
246
+ const {client, close} = await connectClient(dispatch, defaultAuthorizedTools(), (record) =>
247
+ records.push(record),
248
+ );
249
+
250
+ const result = await client.callTool(
251
+ {
252
+ name: 'github_main__issue_read',
253
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
254
+ },
255
+ CallToolResultSchema,
256
+ );
257
+ await close();
258
+
259
+ expect(result.isError).toBe(true);
260
+ expect(records).toMatchObject([
261
+ {
262
+ method: 'get',
263
+ outcome: 'tool-error',
264
+ errorCode: 'provider-rejected',
265
+ providerStatus: 422,
266
+ },
267
+ ]);
231
268
  });
232
269
  });
233
270
 
@@ -6,7 +6,13 @@ import {
6
6
  } from '@modelcontextprotocol/sdk/types.js';
7
7
  import {reportError} from '@shipfox/node-error-monitoring';
8
8
  import {logger} from '@shipfox/node-opentelemetry';
9
- import {INVALID_METHOD_LABEL, type IntegrationToolCallRecorder, NO_METHOD_LABEL} from './audit.js';
9
+ import {normalizeIntegrationAgentToolCallErrorCode} from '#metrics/index.js';
10
+ import {
11
+ INVALID_METHOD_LABEL,
12
+ type IntegrationAgentToolCallErrorCode,
13
+ type IntegrationToolCallRecorder,
14
+ NO_METHOD_LABEL,
15
+ } from './audit.js';
10
16
  import type {
11
17
  AuthorizedIntegrationTool,
12
18
  AuthorizedIntegrationToolMap,
@@ -61,6 +67,7 @@ export function buildAgentToolsMcpServer(params: BuildAgentToolsMcpServerParams)
61
67
  arguments: request.params.arguments ?? {},
62
68
  method: NO_METHOD_LABEL,
63
69
  outcome: 'invalid-request',
70
+ errorCode: 'invalid-request',
64
71
  });
65
72
  return toolError(`Unknown integration tool: ${request.params.name}`);
66
73
  }
@@ -72,6 +79,7 @@ export function buildAgentToolsMcpServer(params: BuildAgentToolsMcpServerParams)
72
79
  arguments: args,
73
80
  method: NO_METHOD_LABEL,
74
81
  outcome: 'invalid-request',
82
+ errorCode: 'invalid-request',
75
83
  });
76
84
  return toolError('Tool arguments must be an object');
77
85
  }
@@ -83,6 +91,7 @@ export function buildAgentToolsMcpServer(params: BuildAgentToolsMcpServerParams)
83
91
  arguments: args,
84
92
  method: INVALID_METHOD_LABEL,
85
93
  outcome: 'invalid-request',
94
+ errorCode: 'invalid-request',
86
95
  });
87
96
  return toolError(methodValidation.message);
88
97
  }
@@ -99,6 +108,7 @@ export function buildAgentToolsMcpServer(params: BuildAgentToolsMcpServerParams)
99
108
  arguments: args,
100
109
  method,
101
110
  outcome: result.isError === true ? 'tool-error' : 'success',
111
+ ...toolCallErrorDetails(result),
102
112
  });
103
113
  return result;
104
114
  } catch (error) {
@@ -107,6 +117,7 @@ export function buildAgentToolsMcpServer(params: BuildAgentToolsMcpServerParams)
107
117
  arguments: args,
108
118
  method,
109
119
  outcome: 'exception',
120
+ errorCode: 'unknown',
110
121
  });
111
122
  throw error;
112
123
  }
@@ -115,6 +126,23 @@ export function buildAgentToolsMcpServer(params: BuildAgentToolsMcpServerParams)
115
126
  return server;
116
127
  }
117
128
 
129
+ function toolCallErrorDetails(result: CallToolResult): {
130
+ errorCode: IntegrationAgentToolCallErrorCode | 'none';
131
+ providerStatus?: number | undefined;
132
+ } {
133
+ if (result.isError !== true) return {errorCode: 'none'};
134
+
135
+ const structuredContent = isRecord(result.structuredContent)
136
+ ? result.structuredContent
137
+ : undefined;
138
+ const providerStatus = statusCode(structuredContent?.status);
139
+
140
+ return {
141
+ errorCode: normalizeIntegrationAgentToolCallErrorCode(structuredContent?.code),
142
+ ...(providerStatus === undefined ? {} : {providerStatus}),
143
+ };
144
+ }
145
+
118
146
  function recordToolCall(
119
147
  recordCall: IntegrationToolCallRecorder | undefined,
120
148
  record: Parameters<IntegrationToolCallRecorder>[0],
@@ -157,3 +185,9 @@ function toolError(message: string): CallToolResult {
157
185
  function isRecord(value: unknown): value is Record<string, unknown> {
158
186
  return typeof value === 'object' && value !== null && !Array.isArray(value);
159
187
  }
188
+
189
+ function statusCode(value: unknown): number | undefined {
190
+ return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599
191
+ ? value
192
+ : undefined;
193
+ }