@shipfox/api-integration-core 12.5.0 → 12.7.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 +21 -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 +2 -0
- package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts.map +1 -1
- package/dist/presentation/routes/agent-tools-gateway/dispatch.js +31 -7
- 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/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/metrics/instance.ts +40 -1
- 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 +62 -2
- package/src/presentation/routes/agent-tools-gateway/dispatch.ts +47 -13
- 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/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.7.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.
|
|
29
|
+
"@shipfox/api-workflows-dto": "12.7.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.
|
|
34
|
+
"@shipfox/api-integration-github": "12.7.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",
|
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
|
+
}
|
|
@@ -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
|
-
{
|
|
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:
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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:
|
|
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: '
|
|
160
|
-
message: 'Integration
|
|
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
|
-
|
|
169
|
-
|
|
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:
|
|
49
|
-
recordCall: createIntegrationToolCallRecorder(
|
|
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([
|
|
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 {
|
|
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
|
+
}
|