@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +32 -0
- package/dist/metrics/instance.d.ts +5 -0
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +21 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/audit.d.ts +4 -1
- package/dist/presentation/routes/agent-tools-gateway/audit.d.ts.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/audit.js +6 -1
- package/dist/presentation/routes/agent-tools-gateway/audit.js.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts +9 -1
- package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/dispatch.js +55 -17
- package/dist/presentation/routes/agent-tools-gateway/dispatch.js.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/index.d.ts.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/index.js +6 -5
- package/dist/presentation/routes/agent-tools-gateway/index.js.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/mcp-server.d.ts.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/mcp-server.js +27 -5
- package/dist/presentation/routes/agent-tools-gateway/mcp-server.js.map +1 -1
- package/dist/presentation/routes/errors.d.ts.map +1 -1
- package/dist/presentation/routes/errors.js +1 -1
- package/dist/presentation/routes/errors.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/metrics/instance.ts +40 -1
- package/src/presentation/routes/agent-tools-gateway/agent-tools-gateway.route.test.ts +11 -4
- package/src/presentation/routes/agent-tools-gateway/audit.test.ts +51 -0
- package/src/presentation/routes/agent-tools-gateway/audit.ts +8 -0
- package/src/presentation/routes/agent-tools-gateway/dispatch.test.ts +167 -0
- package/src/presentation/routes/agent-tools-gateway/dispatch.ts +93 -20
- package/src/presentation/routes/agent-tools-gateway/index.ts +3 -4
- package/src/presentation/routes/agent-tools-gateway/mcp-server.test.ts +41 -4
- package/src/presentation/routes/agent-tools-gateway/mcp-server.ts +35 -1
- package/src/presentation/routes/errors.ts +1 -0
- 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
|
+
"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.
|
|
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.
|
|
31
|
+
"@shipfox/api-integration-spi": "1.1.1",
|
|
32
32
|
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
33
|
-
"@shipfox/api-integration-gitea": "12.
|
|
34
|
-
"@shipfox/api-integration-github": "12.
|
|
35
|
-
"@shipfox/api-integration-jira": "12.
|
|
36
|
-
"@shipfox/api-integration-linear": "12.
|
|
37
|
-
"@shipfox/api-integration-sentry": "12.
|
|
38
|
-
"@shipfox/api-integration-slack": "12.
|
|
39
|
-
"@shipfox/api-integration-webhook": "12.
|
|
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",
|
package/src/metrics/instance.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
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: '
|
|
273
|
-
structuredContent: {code: 'provider-
|
|
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
|
-
|
|
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) =>
|
|
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 & {
|
|
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
|
-
{
|
|
57
|
-
|
|
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(
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
127
|
-
message: 'Integration
|
|
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
|
-
|
|
136
|
-
|
|
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:
|
|
212
|
+
function toolError(params: IntegrationToolError): CallToolResult {
|
|
146
213
|
return {
|
|
147
214
|
isError: true,
|
|
148
215
|
content: [{type: 'text', text: params.message}],
|
|
149
|
-
structuredContent: {
|
|
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:
|
|
49
|
-
recordCall: createIntegrationToolCallRecorder(
|
|
47
|
+
dispatch: createIntegrationToolDispatcher({registry: params.registry, lease}),
|
|
48
|
+
recordCall: createIntegrationToolCallRecorder(lease),
|
|
50
49
|
});
|
|
51
50
|
const transport = new StreamableHTTPServerTransport();
|
|
52
51
|
|