@shipfox/api-integration-core 2.0.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 (275) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +264 -0
  5. package/LICENSE +21 -0
  6. package/dist/config.d.ts +9 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +29 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/core/agent-tool-selection.d.ts +15 -0
  11. package/dist/core/agent-tool-selection.d.ts.map +1 -0
  12. package/dist/core/agent-tool-selection.js +48 -0
  13. package/dist/core/agent-tool-selection.js.map +1 -0
  14. package/dist/core/entities/connection.d.ts +2 -0
  15. package/dist/core/entities/connection.d.ts.map +1 -0
  16. package/dist/core/entities/connection.js +3 -0
  17. package/dist/core/entities/connection.js.map +1 -0
  18. package/dist/core/entities/provider.d.ts +6 -0
  19. package/dist/core/entities/provider.d.ts.map +1 -0
  20. package/dist/core/entities/provider.js +3 -0
  21. package/dist/core/entities/provider.js.map +1 -0
  22. package/dist/core/errors.d.ts +29 -0
  23. package/dist/core/errors.d.ts.map +1 -0
  24. package/dist/core/errors.js +40 -0
  25. package/dist/core/errors.js.map +1 -0
  26. package/dist/core/providers/agent-tools.d.ts +2 -0
  27. package/dist/core/providers/agent-tools.d.ts.map +1 -0
  28. package/dist/core/providers/agent-tools.js +3 -0
  29. package/dist/core/providers/agent-tools.js.map +1 -0
  30. package/dist/core/providers/redact-checkout-spec.d.ts +9 -0
  31. package/dist/core/providers/redact-checkout-spec.d.ts.map +1 -0
  32. package/dist/core/providers/redact-checkout-spec.js +25 -0
  33. package/dist/core/providers/redact-checkout-spec.js.map +1 -0
  34. package/dist/core/providers/registry.d.ts +9 -0
  35. package/dist/core/providers/registry.d.ts.map +1 -0
  36. package/dist/core/providers/registry.js +52 -0
  37. package/dist/core/providers/registry.js.map +1 -0
  38. package/dist/core/providers/source-control.d.ts +2 -0
  39. package/dist/core/providers/source-control.d.ts.map +1 -0
  40. package/dist/core/providers/source-control.js +3 -0
  41. package/dist/core/providers/source-control.js.map +1 -0
  42. package/dist/core/source-control-service.d.ts +46 -0
  43. package/dist/core/source-control-service.d.ts.map +1 -0
  44. package/dist/core/source-control-service.js +84 -0
  45. package/dist/core/source-control-service.js.map +1 -0
  46. package/dist/db/connections.d.ts +67 -0
  47. package/dist/db/connections.d.ts.map +1 -0
  48. package/dist/db/connections.js +130 -0
  49. package/dist/db/connections.js.map +1 -0
  50. package/dist/db/db.d.ts +865 -0
  51. package/dist/db/db.d.ts.map +1 -0
  52. package/dist/db/db.js +22 -0
  53. package/dist/db/db.js.map +1 -0
  54. package/dist/db/migrations.d.ts +2 -0
  55. package/dist/db/migrations.d.ts.map +1 -0
  56. package/dist/db/migrations.js +5 -0
  57. package/dist/db/migrations.js.map +1 -0
  58. package/dist/db/schema/common.d.ts +2 -0
  59. package/dist/db/schema/common.d.ts.map +1 -0
  60. package/dist/db/schema/common.js +4 -0
  61. package/dist/db/schema/common.js.map +1 -0
  62. package/dist/db/schema/connections.d.ts +165 -0
  63. package/dist/db/schema/connections.d.ts.map +1 -0
  64. package/dist/db/schema/connections.js +37 -0
  65. package/dist/db/schema/connections.js.map +1 -0
  66. package/dist/db/schema/outbox.d.ts +195 -0
  67. package/dist/db/schema/outbox.d.ts.map +1 -0
  68. package/dist/db/schema/outbox.js +5 -0
  69. package/dist/db/schema/outbox.js.map +1 -0
  70. package/dist/db/schema/webhook-deliveries.d.ts +76 -0
  71. package/dist/db/schema/webhook-deliveries.d.ts.map +1 -0
  72. package/dist/db/schema/webhook-deliveries.js +22 -0
  73. package/dist/db/schema/webhook-deliveries.js.map +1 -0
  74. package/dist/db/webhook-deliveries.d.ts +55 -0
  75. package/dist/db/webhook-deliveries.d.ts.map +1 -0
  76. package/dist/db/webhook-deliveries.js +132 -0
  77. package/dist/db/webhook-deliveries.js.map +1 -0
  78. package/dist/index.d.ts +54 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +105 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/metrics/index.d.ts +2 -0
  83. package/dist/metrics/index.d.ts.map +1 -0
  84. package/dist/metrics/index.js +3 -0
  85. package/dist/metrics/index.js.map +1 -0
  86. package/dist/metrics/instance.d.ts +8 -0
  87. package/dist/metrics/instance.d.ts.map +1 -0
  88. package/dist/metrics/instance.js +17 -0
  89. package/dist/metrics/instance.js.map +1 -0
  90. package/dist/presentation/dto/integrations.d.ts +36 -0
  91. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  92. package/dist/presentation/dto/integrations.js +26 -0
  93. package/dist/presentation/dto/integrations.js.map +1 -0
  94. package/dist/presentation/routes/agent-tools-gateway/audit.d.ts +24 -0
  95. package/dist/presentation/routes/agent-tools-gateway/audit.d.ts.map +1 -0
  96. package/dist/presentation/routes/agent-tools-gateway/audit.js +52 -0
  97. package/dist/presentation/routes/agent-tools-gateway/audit.js.map +1 -0
  98. package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts +7 -0
  99. package/dist/presentation/routes/agent-tools-gateway/dispatch.d.ts.map +1 -0
  100. package/dist/presentation/routes/agent-tools-gateway/dispatch.js +108 -0
  101. package/dist/presentation/routes/agent-tools-gateway/dispatch.js.map +1 -0
  102. package/dist/presentation/routes/agent-tools-gateway/index.d.ts +12 -0
  103. package/dist/presentation/routes/agent-tools-gateway/index.d.ts.map +1 -0
  104. package/dist/presentation/routes/agent-tools-gateway/index.js +46 -0
  105. package/dist/presentation/routes/agent-tools-gateway/index.js.map +1 -0
  106. package/dist/presentation/routes/agent-tools-gateway/mcp-server.d.ts +17 -0
  107. package/dist/presentation/routes/agent-tools-gateway/mcp-server.d.ts.map +1 -0
  108. package/dist/presentation/routes/agent-tools-gateway/mcp-server.js +126 -0
  109. package/dist/presentation/routes/agent-tools-gateway/mcp-server.js.map +1 -0
  110. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts +37 -0
  111. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts.map +1 -0
  112. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js +145 -0
  113. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js.map +1 -0
  114. package/dist/presentation/routes/errors.d.ts +2 -0
  115. package/dist/presentation/routes/errors.d.ts.map +1 -0
  116. package/dist/presentation/routes/errors.js +53 -0
  117. package/dist/presentation/routes/errors.js.map +1 -0
  118. package/dist/presentation/routes/index.d.ts +14 -0
  119. package/dist/presentation/routes/index.d.ts.map +1 -0
  120. package/dist/presentation/routes/index.js +26 -0
  121. package/dist/presentation/routes/index.js.map +1 -0
  122. package/dist/presentation/routes/list-connections.d.ts +3 -0
  123. package/dist/presentation/routes/list-connections.d.ts.map +1 -0
  124. package/dist/presentation/routes/list-connections.js +58 -0
  125. package/dist/presentation/routes/list-connections.js.map +1 -0
  126. package/dist/presentation/routes/list-providers.d.ts +3 -0
  127. package/dist/presentation/routes/list-providers.d.ts.map +1 -0
  128. package/dist/presentation/routes/list-providers.js +27 -0
  129. package/dist/presentation/routes/list-providers.js.map +1 -0
  130. package/dist/presentation/routes/list-repositories.d.ts +3 -0
  131. package/dist/presentation/routes/list-repositories.d.ts.map +1 -0
  132. package/dist/presentation/routes/list-repositories.js +41 -0
  133. package/dist/presentation/routes/list-repositories.js.map +1 -0
  134. package/dist/presentation/routes/manage-connections.d.ts +4 -0
  135. package/dist/presentation/routes/manage-connections.d.ts.map +1 -0
  136. package/dist/presentation/routes/manage-connections.js +81 -0
  137. package/dist/presentation/routes/manage-connections.js.map +1 -0
  138. package/dist/providers/connection-slug.d.ts +2 -0
  139. package/dist/providers/connection-slug.d.ts.map +1 -0
  140. package/dist/providers/connection-slug.js +19 -0
  141. package/dist/providers/connection-slug.js.map +1 -0
  142. package/dist/providers/cron.d.ts +3 -0
  143. package/dist/providers/cron.d.ts.map +1 -0
  144. package/dist/providers/cron.js +13 -0
  145. package/dist/providers/cron.js.map +1 -0
  146. package/dist/providers/gitea.d.ts +3 -0
  147. package/dist/providers/gitea.d.ts.map +1 -0
  148. package/dist/providers/gitea.js +75 -0
  149. package/dist/providers/gitea.js.map +1 -0
  150. package/dist/providers/github.d.ts +3 -0
  151. package/dist/providers/github.d.ts.map +1 -0
  152. package/dist/providers/github.js +109 -0
  153. package/dist/providers/github.js.map +1 -0
  154. package/dist/providers/linear.d.ts +3 -0
  155. package/dist/providers/linear.d.ts.map +1 -0
  156. package/dist/providers/linear.js +134 -0
  157. package/dist/providers/linear.js.map +1 -0
  158. package/dist/providers/modules.d.ts +3 -0
  159. package/dist/providers/modules.d.ts.map +1 -0
  160. package/dist/providers/modules.js +26 -0
  161. package/dist/providers/modules.js.map +1 -0
  162. package/dist/providers/sentry.d.ts +3 -0
  163. package/dist/providers/sentry.d.ts.map +1 -0
  164. package/dist/providers/sentry.js +83 -0
  165. package/dist/providers/sentry.js.map +1 -0
  166. package/dist/providers/types.d.ts +58 -0
  167. package/dist/providers/types.d.ts.map +1 -0
  168. package/dist/providers/types.js +3 -0
  169. package/dist/providers/types.js.map +1 -0
  170. package/dist/providers/webhook.d.ts +3 -0
  171. package/dist/providers/webhook.d.ts.map +1 -0
  172. package/dist/providers/webhook.js +24 -0
  173. package/dist/providers/webhook.js.map +1 -0
  174. package/dist/temporal/activities/index.d.ts +5 -0
  175. package/dist/temporal/activities/index.d.ts.map +1 -0
  176. package/dist/temporal/activities/index.js +8 -0
  177. package/dist/temporal/activities/index.js.map +1 -0
  178. package/dist/temporal/activities/prune-webhook-deliveries.d.ts +4 -0
  179. package/dist/temporal/activities/prune-webhook-deliveries.d.ts.map +1 -0
  180. package/dist/temporal/activities/prune-webhook-deliveries.js +11 -0
  181. package/dist/temporal/activities/prune-webhook-deliveries.js.map +1 -0
  182. package/dist/temporal/constants.d.ts +3 -0
  183. package/dist/temporal/constants.d.ts.map +1 -0
  184. package/dist/temporal/constants.js +4 -0
  185. package/dist/temporal/constants.js.map +1 -0
  186. package/dist/temporal/workflows/index.d.ts +2 -0
  187. package/dist/temporal/workflows/index.d.ts.map +1 -0
  188. package/dist/temporal/workflows/index.js +3 -0
  189. package/dist/temporal/workflows/index.js.map +1 -0
  190. package/dist/temporal/workflows/prune-webhook-deliveries-cron.d.ts +2 -0
  191. package/dist/temporal/workflows/prune-webhook-deliveries-cron.d.ts.map +1 -0
  192. package/dist/temporal/workflows/prune-webhook-deliveries-cron.js +14 -0
  193. package/dist/temporal/workflows/prune-webhook-deliveries-cron.js.map +1 -0
  194. package/dist/tsconfig.test.tsbuildinfo +1 -0
  195. package/drizzle/0000_initial.sql +41 -0
  196. package/drizzle/meta/0001_snapshot.json +341 -0
  197. package/drizzle/meta/_journal.json +13 -0
  198. package/drizzle.config.ts +7 -0
  199. package/package.json +85 -0
  200. package/src/config.test.ts +32 -0
  201. package/src/config.ts +28 -0
  202. package/src/core/agent-tool-selection.test.ts +137 -0
  203. package/src/core/agent-tool-selection.ts +80 -0
  204. package/src/core/entities/connection.ts +4 -0
  205. package/src/core/entities/provider.ts +21 -0
  206. package/src/core/errors.ts +55 -0
  207. package/src/core/providers/agent-tools.ts +10 -0
  208. package/src/core/providers/redact-checkout-spec.test.ts +78 -0
  209. package/src/core/providers/redact-checkout-spec.ts +20 -0
  210. package/src/core/providers/registry.test.ts +138 -0
  211. package/src/core/providers/registry.ts +90 -0
  212. package/src/core/providers/source-control.ts +17 -0
  213. package/src/core/source-control-service.test.ts +230 -0
  214. package/src/core/source-control-service.ts +161 -0
  215. package/src/db/connections.test.ts +296 -0
  216. package/src/db/connections.ts +262 -0
  217. package/src/db/db.ts +22 -0
  218. package/src/db/migrations.ts +4 -0
  219. package/src/db/schema/common.ts +3 -0
  220. package/src/db/schema/connections.ts +49 -0
  221. package/src/db/schema/outbox.ts +4 -0
  222. package/src/db/schema/webhook-deliveries.ts +19 -0
  223. package/src/db/webhook-deliveries.test.ts +267 -0
  224. package/src/db/webhook-deliveries.ts +207 -0
  225. package/src/index.ts +212 -0
  226. package/src/metrics/index.ts +1 -0
  227. package/src/metrics/instance.ts +35 -0
  228. package/src/presentation/dto/integrations.ts +39 -0
  229. package/src/presentation/routes/agent-tools-gateway/agent-tools-gateway.route.test.ts +371 -0
  230. package/src/presentation/routes/agent-tools-gateway/audit.test.ts +86 -0
  231. package/src/presentation/routes/agent-tools-gateway/audit.ts +90 -0
  232. package/src/presentation/routes/agent-tools-gateway/dispatch.ts +139 -0
  233. package/src/presentation/routes/agent-tools-gateway/index.ts +62 -0
  234. package/src/presentation/routes/agent-tools-gateway/mcp-server.test.ts +308 -0
  235. package/src/presentation/routes/agent-tools-gateway/mcp-server.ts +155 -0
  236. package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.test.ts +181 -0
  237. package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.ts +245 -0
  238. package/src/presentation/routes/errors.ts +63 -0
  239. package/src/presentation/routes/index.ts +53 -0
  240. package/src/presentation/routes/list-connections.test.ts +227 -0
  241. package/src/presentation/routes/list-connections.ts +56 -0
  242. package/src/presentation/routes/list-providers.test.ts +113 -0
  243. package/src/presentation/routes/list-providers.ts +28 -0
  244. package/src/presentation/routes/list-repositories.test.ts +150 -0
  245. package/src/presentation/routes/list-repositories.ts +47 -0
  246. package/src/presentation/routes/manage-connections.test.ts +125 -0
  247. package/src/presentation/routes/manage-connections.ts +77 -0
  248. package/src/providers/connection-slug.ts +19 -0
  249. package/src/providers/cron.ts +13 -0
  250. package/src/providers/gitea.ts +103 -0
  251. package/src/providers/github.ts +150 -0
  252. package/src/providers/linear.ts +179 -0
  253. package/src/providers/modules.test.ts +40 -0
  254. package/src/providers/modules.ts +32 -0
  255. package/src/providers/sentry.ts +113 -0
  256. package/src/providers/types.ts +52 -0
  257. package/src/providers/webhook.ts +30 -0
  258. package/src/temporal/activities/index.ts +7 -0
  259. package/src/temporal/activities/prune-webhook-deliveries.test.ts +39 -0
  260. package/src/temporal/activities/prune-webhook-deliveries.ts +9 -0
  261. package/src/temporal/constants.ts +3 -0
  262. package/src/temporal/workflows/index.ts +1 -0
  263. package/src/temporal/workflows/prune-webhook-deliveries-cron.ts +15 -0
  264. package/test/agent-tools-gateway-helpers.ts +210 -0
  265. package/test/env.ts +23 -0
  266. package/test/factories/connection.ts +21 -0
  267. package/test/globalSetup.ts +22 -0
  268. package/test/index.ts +1 -0
  269. package/test/route-utils.ts +104 -0
  270. package/test/setup.ts +19 -0
  271. package/tsconfig.build.json +9 -0
  272. package/tsconfig.build.tsbuildinfo +1 -0
  273. package/tsconfig.json +3 -0
  274. package/tsconfig.test.json +8 -0
  275. package/vitest.config.ts +13 -0
@@ -0,0 +1,371 @@
1
+ import {Client} from '@modelcontextprotocol/sdk/client/index.js';
2
+ import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import type {Transport} from '@modelcontextprotocol/sdk/shared/transport.js';
4
+ import {CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js';
5
+ import {
6
+ AUTH_LEASED_JOB,
7
+ type LeasedJobContext,
8
+ setLeasedJobContext,
9
+ } from '@shipfox/api-auth-context';
10
+ import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
11
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
12
+ import {
13
+ agentStepConfig,
14
+ catalogTool,
15
+ connection,
16
+ leaseContext,
17
+ materializedIntegration,
18
+ registryWithAgentTools,
19
+ } from '#test/agent-tools-gateway-helpers.js';
20
+ import {createAgentToolsGatewayRoutes} from './index.js';
21
+
22
+ let leases = new Map<string, LeasedJobContext>();
23
+
24
+ const fakeLeaseAuth: AuthMethod = {
25
+ name: AUTH_LEASED_JOB,
26
+ authenticate: (request: FastifyRequest) => {
27
+ const authorization = request.headers.authorization;
28
+ const token =
29
+ typeof authorization === 'string' && authorization.startsWith('Bearer ')
30
+ ? authorization.slice('Bearer '.length)
31
+ : null;
32
+ const lease = token ? leases.get(token) : undefined;
33
+ if (!lease) {
34
+ throw new ClientError('Invalid job lease token', 'unauthorized', {status: 401});
35
+ }
36
+
37
+ setLeasedJobContext(request, lease);
38
+ return Promise.resolve();
39
+ },
40
+ };
41
+
42
+ describe('agent tools gateway route', () => {
43
+ beforeEach(async () => {
44
+ await closeApp();
45
+ leases = new Map();
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await closeApp();
50
+ });
51
+
52
+ it('requires lease auth before resolving tools', async () => {
53
+ const app = await createGatewayApp();
54
+
55
+ const res = await app.inject({
56
+ method: 'POST',
57
+ url: '/runs/jobs/current/integration-tools/mcp',
58
+ payload: {},
59
+ });
60
+
61
+ expect(res.statusCode).toBe(401);
62
+ });
63
+
64
+ it('returns a normal HTTP error for pre-hijack resolution failures', async () => {
65
+ const lease = leaseContext({currentStepId: undefined, currentStepAttempt: undefined});
66
+ leases.set('lease-without-step', lease);
67
+ const app = await createGatewayApp();
68
+
69
+ const res = await app.inject({
70
+ method: 'POST',
71
+ url: '/runs/jobs/current/integration-tools/mcp',
72
+ headers: {authorization: 'Bearer lease-without-step'},
73
+ payload: {},
74
+ });
75
+
76
+ expect(res.statusCode).toBe(409);
77
+ expect(res.json().code).toBe('lease-missing-step');
78
+ });
79
+
80
+ it('serves MCP over stateless Streamable HTTP after resolving server-side state', async () => {
81
+ const lease = leaseContext({workspaceId: 'workspace-1'});
82
+ const integration = materializedIntegration({connectionId: 'connection-1'});
83
+ const calls: unknown[] = [];
84
+ const sessionTools: unknown[] = [];
85
+ const close = vi.fn();
86
+ leases.set('valid-lease', lease);
87
+ const app = await createGatewayApp({
88
+ registry: registryWithAgentTools([catalogTool()], {
89
+ onOpenSession: (input) => sessionTools.push(...input.tools),
90
+ onCall: (call) => calls.push(call),
91
+ onClose: close,
92
+ }),
93
+ loadLeasedAgentStep: async () => ({
94
+ workspaceId: lease.workspaceId,
95
+ step: {type: 'agent', config: agentStepConfig([integration])},
96
+ }),
97
+ getIntegrationConnectionById: async () =>
98
+ connection({
99
+ id: integration.connectionId,
100
+ workspaceId: lease.workspaceId,
101
+ slug: integration.connectionSlug,
102
+ }),
103
+ });
104
+ const address = await app.listen({port: 0, host: '127.0.0.1'});
105
+ const client = new Client({name: 'test-http-client', version: '0.0.0'});
106
+ const transport = new StreamableHTTPClientTransport(
107
+ new URL('/runs/jobs/current/integration-tools/mcp', address),
108
+ {
109
+ requestInit: {
110
+ headers: {authorization: 'Bearer valid-lease'},
111
+ },
112
+ },
113
+ );
114
+
115
+ await client.connect(transport as unknown as Transport);
116
+ const tools = await client.listTools();
117
+ const result = await client.callTool(
118
+ {
119
+ name: 'github_main__issue_read',
120
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
121
+ },
122
+ CallToolResultSchema,
123
+ );
124
+ await client.close();
125
+
126
+ expect(tools.tools.map((tool) => tool.name)).toEqual(['github_main__issue_read']);
127
+ expect(result.isError).not.toBe(true);
128
+ expect(result.structuredContent).toMatchObject({
129
+ status: 'dispatched',
130
+ provider: 'github',
131
+ connection_id: 'connection-1',
132
+ tool_id: 'issue_read',
133
+ method: 'get',
134
+ });
135
+ expect(calls).toEqual([
136
+ {
137
+ toolId: 'issue_read',
138
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
139
+ },
140
+ ]);
141
+ expect(sessionTools).toMatchObject([
142
+ {
143
+ methods: [
144
+ {id: 'get', description: 'Get one issue.'},
145
+ {id: 'get_comments', description: 'Get issue comments.'},
146
+ ],
147
+ },
148
+ ]);
149
+ expect(close).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it('returns bounded MCP errors when provider dispatch times out', async () => {
153
+ const lease = leaseContext({workspaceId: 'workspace-1'});
154
+ const integration = materializedIntegration({connectionId: 'connection-1'});
155
+ const timeout = new Error('request timeout after 30s');
156
+ timeout.name = 'TimeoutError';
157
+ const close = vi.fn();
158
+ leases.set('timeout-lease', lease);
159
+ const app = await createGatewayApp({
160
+ registry: registryWithAgentTools([catalogTool()], {callError: timeout, onClose: close}),
161
+ loadLeasedAgentStep: async () => ({
162
+ workspaceId: lease.workspaceId,
163
+ step: {type: 'agent', config: agentStepConfig([integration])},
164
+ }),
165
+ getIntegrationConnectionById: async () =>
166
+ connection({
167
+ id: integration.connectionId,
168
+ workspaceId: lease.workspaceId,
169
+ slug: integration.connectionSlug,
170
+ }),
171
+ });
172
+ const address = await app.listen({port: 0, host: '127.0.0.1'});
173
+ const client = new Client({name: 'test-http-client', version: '0.0.0'});
174
+ const transport = new StreamableHTTPClientTransport(
175
+ new URL('/runs/jobs/current/integration-tools/mcp', address),
176
+ {
177
+ requestInit: {
178
+ headers: {authorization: 'Bearer timeout-lease'},
179
+ },
180
+ },
181
+ );
182
+
183
+ await client.connect(transport as unknown as Transport);
184
+ const result = await client.callTool(
185
+ {
186
+ name: 'github_main__issue_read',
187
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
188
+ },
189
+ CallToolResultSchema,
190
+ );
191
+ await client.close();
192
+
193
+ expect(result).toEqual({
194
+ isError: true,
195
+ content: [{type: 'text', text: 'Integration provider timed out'}],
196
+ structuredContent: {code: 'provider-timeout'},
197
+ });
198
+ expect(close).toHaveBeenCalledTimes(1);
199
+ });
200
+
201
+ it('classifies the MCP SDK request timeout shape as a provider timeout', async () => {
202
+ const lease = leaseContext({workspaceId: 'workspace-1'});
203
+ const integration = materializedIntegration({connectionId: 'connection-1'});
204
+ const timeout = new Error('MCP error -32001: Request timed out');
205
+ timeout.name = 'McpError';
206
+ leases.set('mcp-timeout-lease', lease);
207
+ const app = await createGatewayApp({
208
+ registry: registryWithAgentTools([catalogTool()], {callError: timeout}),
209
+ loadLeasedAgentStep: async () => ({
210
+ workspaceId: lease.workspaceId,
211
+ step: {type: 'agent', config: agentStepConfig([integration])},
212
+ }),
213
+ getIntegrationConnectionById: async () =>
214
+ connection({
215
+ id: integration.connectionId,
216
+ workspaceId: lease.workspaceId,
217
+ slug: integration.connectionSlug,
218
+ }),
219
+ });
220
+
221
+ const result = await callIssueReadTool(app, 'mcp-timeout-lease');
222
+
223
+ expect(result).toEqual({
224
+ isError: true,
225
+ content: [{type: 'text', text: 'Integration provider timed out'}],
226
+ structuredContent: {code: 'provider-timeout'},
227
+ });
228
+ });
229
+
230
+ it('returns bounded MCP errors when provider dispatch fails', async () => {
231
+ const lease = leaseContext({workspaceId: 'workspace-1'});
232
+ const integration = materializedIntegration({connectionId: 'connection-1'});
233
+ leases.set('provider-error-lease', lease);
234
+ const app = await createGatewayApp({
235
+ registry: registryWithAgentTools([catalogTool()], {
236
+ callError: new Error('remote provider leaked implementation detail'),
237
+ }),
238
+ loadLeasedAgentStep: async () => ({
239
+ workspaceId: lease.workspaceId,
240
+ step: {type: 'agent', config: agentStepConfig([integration])},
241
+ }),
242
+ getIntegrationConnectionById: async () =>
243
+ connection({
244
+ id: integration.connectionId,
245
+ workspaceId: lease.workspaceId,
246
+ slug: integration.connectionSlug,
247
+ }),
248
+ });
249
+ const address = await app.listen({port: 0, host: '127.0.0.1'});
250
+ const client = new Client({name: 'test-http-client', version: '0.0.0'});
251
+ const transport = new StreamableHTTPClientTransport(
252
+ new URL('/runs/jobs/current/integration-tools/mcp', address),
253
+ {
254
+ requestInit: {
255
+ headers: {authorization: 'Bearer provider-error-lease'},
256
+ },
257
+ },
258
+ );
259
+
260
+ await client.connect(transport as unknown as Transport);
261
+ const result = await client.callTool(
262
+ {
263
+ name: 'github_main__issue_read',
264
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
265
+ },
266
+ CallToolResultSchema,
267
+ );
268
+ await client.close();
269
+
270
+ expect(result).toEqual({
271
+ isError: true,
272
+ content: [{type: 'text', text: 'Integration provider call failed'}],
273
+ structuredContent: {code: 'provider-unavailable'},
274
+ });
275
+ });
276
+
277
+ it('returns bounded MCP errors when provider credentials are unavailable', async () => {
278
+ const lease = leaseContext({workspaceId: 'workspace-1'});
279
+ const integration = materializedIntegration({connectionId: 'connection-1'});
280
+ const tokenError = new Error('missing token for connection-1');
281
+ tokenError.name = 'LinearAccessTokenMissingError';
282
+ leases.set('credential-error-lease', lease);
283
+ const app = await createGatewayApp({
284
+ registry: registryWithAgentTools([catalogTool()], {openSessionError: tokenError}),
285
+ loadLeasedAgentStep: async () => ({
286
+ workspaceId: lease.workspaceId,
287
+ step: {type: 'agent', config: agentStepConfig([integration])},
288
+ }),
289
+ getIntegrationConnectionById: async () =>
290
+ connection({
291
+ id: integration.connectionId,
292
+ workspaceId: lease.workspaceId,
293
+ slug: integration.connectionSlug,
294
+ }),
295
+ });
296
+ const address = await app.listen({port: 0, host: '127.0.0.1'});
297
+ const client = new Client({name: 'test-http-client', version: '0.0.0'});
298
+ const transport = new StreamableHTTPClientTransport(
299
+ new URL('/runs/jobs/current/integration-tools/mcp', address),
300
+ {
301
+ requestInit: {
302
+ headers: {authorization: 'Bearer credential-error-lease'},
303
+ },
304
+ },
305
+ );
306
+
307
+ await client.connect(transport as unknown as Transport);
308
+ const result = await client.callTool(
309
+ {
310
+ name: 'github_main__issue_read',
311
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
312
+ },
313
+ CallToolResultSchema,
314
+ );
315
+ await client.close();
316
+
317
+ expect(result).toEqual({
318
+ isError: true,
319
+ content: [{type: 'text', text: 'Integration provider credentials are unavailable'}],
320
+ structuredContent: {code: 'credentials-unavailable'},
321
+ });
322
+ });
323
+ });
324
+
325
+ async function createGatewayApp(
326
+ overrides: Partial<Parameters<typeof createAgentToolsGatewayRoutes>[0]> = {},
327
+ ): Promise<FastifyInstance> {
328
+ const app = await createApp({
329
+ auth: [fakeLeaseAuth],
330
+ routes: [
331
+ createAgentToolsGatewayRoutes({
332
+ registry: registryWithAgentTools(),
333
+ getIntegrationConnectionById: async () =>
334
+ connection({workspaceId: 'workspace-1', id: 'connection-1'}),
335
+ loadLeasedAgentStep: async () => ({
336
+ workspaceId: 'workspace-1',
337
+ step: {type: 'agent', config: agentStepConfig()},
338
+ }),
339
+ ...overrides,
340
+ }),
341
+ ],
342
+ swagger: false,
343
+ });
344
+ await app.ready();
345
+ return app;
346
+ }
347
+
348
+ async function callIssueReadTool(app: FastifyInstance, leaseToken: string) {
349
+ const address = await app.listen({port: 0, host: '127.0.0.1'});
350
+ const client = new Client({name: 'test-http-client', version: '0.0.0'});
351
+ const transport = new StreamableHTTPClientTransport(
352
+ new URL('/runs/jobs/current/integration-tools/mcp', address),
353
+ {
354
+ requestInit: {
355
+ headers: {authorization: `Bearer ${leaseToken}`},
356
+ },
357
+ },
358
+ );
359
+
360
+ await client.connect(transport as unknown as Transport);
361
+ const result = await client.callTool(
362
+ {
363
+ name: 'github_main__issue_read',
364
+ arguments: {method: 'get', owner: 'shipfox', repo: 'platform', issue_number: 1},
365
+ },
366
+ CallToolResultSchema,
367
+ );
368
+ await client.close();
369
+
370
+ return result;
371
+ }
@@ -0,0 +1,86 @@
1
+ import {
2
+ connection,
3
+ leaseContext,
4
+ materializedIntegration,
5
+ materializedTool,
6
+ } from '#test/agent-tools-gateway-helpers.js';
7
+ import {createIntegrationToolCallRecorder, summarizeIntegrationToolArguments} from './audit.js';
8
+
9
+ describe('integration tool call audit', () => {
10
+ it('records bounded metric labels and structured audit context', () => {
11
+ const recordMetric = vi.fn();
12
+ const logInfo = vi.fn();
13
+ const lease = leaseContext({
14
+ jobId: 'job-1',
15
+ jobExecutionId: 'execution-1',
16
+ workflowRunId: 'run-1',
17
+ workflowRunAttemptId: 'attempt-1',
18
+ workspaceId: 'workspace-1',
19
+ currentStepId: 'step-1',
20
+ currentStepAttempt: 2,
21
+ });
22
+ const integration = materializedIntegration({connectionId: 'connection-1'});
23
+ const tool = materializedTool();
24
+ const recorder = createIntegrationToolCallRecorder(lease, {recordMetric, logInfo});
25
+
26
+ recorder({
27
+ authorizedTool: {
28
+ mcpName: 'github_main__issue_read',
29
+ integration,
30
+ tool,
31
+ connection: connection({
32
+ id: 'connection-1',
33
+ workspaceId: 'workspace-1',
34
+ slug: integration.connectionSlug,
35
+ }),
36
+ description: 'Read issues',
37
+ inputSchema: tool.inputSchema,
38
+ },
39
+ arguments: {
40
+ repo: 'platform',
41
+ owner: 'shipfox',
42
+ token: 'must-not-appear',
43
+ },
44
+ method: 'get',
45
+ outcome: 'success',
46
+ });
47
+
48
+ expect(recordMetric).toHaveBeenCalledWith({
49
+ provider: 'github',
50
+ tool: 'issue_read',
51
+ method: 'get',
52
+ outcome: 'success',
53
+ });
54
+ expect(logInfo).toHaveBeenCalledWith(
55
+ expect.objectContaining({
56
+ jobId: 'job-1',
57
+ jobExecutionId: 'execution-1',
58
+ workflowRunId: 'run-1',
59
+ workflowRunAttemptId: 'attempt-1',
60
+ workspaceId: 'workspace-1',
61
+ currentStepId: 'step-1',
62
+ currentStepAttempt: 2,
63
+ connectionId: 'connection-1',
64
+ provider: 'github',
65
+ toolId: 'issue_read',
66
+ method: 'get',
67
+ outcome: 'success',
68
+ argumentSummary: {
69
+ keys: ['owner', 'repo', 'token'],
70
+ serializedSizeBytes: expect.any(Number),
71
+ },
72
+ }),
73
+ 'integration tool call audited',
74
+ );
75
+ expect(JSON.stringify(logInfo.mock.calls)).not.toContain('must-not-appear');
76
+ });
77
+
78
+ it('summarizes arguments without values', () => {
79
+ const summary = summarizeIntegrationToolArguments({z: 'secret', a: 1});
80
+
81
+ expect(summary).toEqual({
82
+ keys: ['a', 'z'],
83
+ serializedSizeBytes: 20,
84
+ });
85
+ });
86
+ });
@@ -0,0 +1,90 @@
1
+ import type {LeasedJobContext} from '@shipfox/api-auth-context';
2
+ import {logger} from '@shipfox/node-opentelemetry';
3
+ import {
4
+ type IntegrationAgentToolCallOutcome,
5
+ recordIntegrationAgentToolCall,
6
+ } from '#metrics/index.js';
7
+ import type {AuthorizedIntegrationTool} from './resolve-authorized-tools.js';
8
+
9
+ export const UNKNOWN_TOOL_LABEL = 'unknown';
10
+ export const NO_METHOD_LABEL = 'none';
11
+ export const INVALID_METHOD_LABEL = 'invalid';
12
+
13
+ export interface IntegrationToolArgumentSummary {
14
+ keys: string[];
15
+ serializedSizeBytes: number;
16
+ }
17
+
18
+ export interface IntegrationToolCallAuditRecord {
19
+ authorizedTool?: AuthorizedIntegrationTool | undefined;
20
+ arguments: unknown;
21
+ method: string;
22
+ outcome: IntegrationAgentToolCallOutcome;
23
+ }
24
+
25
+ export type IntegrationToolCallRecorder = (record: IntegrationToolCallAuditRecord) => void;
26
+
27
+ export interface CreateIntegrationToolCallRecorderOptions {
28
+ recordMetric?: typeof recordIntegrationAgentToolCall | undefined;
29
+ logInfo?:
30
+ | ((context: Record<string, unknown>, message: 'integration tool call audited') => void)
31
+ | undefined;
32
+ }
33
+
34
+ export function createIntegrationToolCallRecorder(
35
+ lease: LeasedJobContext,
36
+ options: CreateIntegrationToolCallRecorderOptions = {},
37
+ ): IntegrationToolCallRecorder {
38
+ const recordMetric = options.recordMetric ?? recordIntegrationAgentToolCall;
39
+ const logInfo = options.logInfo ?? ((context, message) => logger().info(context, message));
40
+
41
+ return (record) => {
42
+ const provider = record.authorizedTool?.integration.provider ?? UNKNOWN_TOOL_LABEL;
43
+ const toolId = record.authorizedTool?.tool.id ?? UNKNOWN_TOOL_LABEL;
44
+
45
+ recordMetric({
46
+ provider,
47
+ tool: toolId,
48
+ method: record.method,
49
+ outcome: record.outcome,
50
+ });
51
+
52
+ logInfo(
53
+ {
54
+ jobId: lease.jobId,
55
+ jobExecutionId: lease.jobExecutionId,
56
+ workflowRunId: lease.workflowRunId,
57
+ workflowRunAttemptId: lease.workflowRunAttemptId,
58
+ workspaceId: lease.workspaceId,
59
+ currentStepId: lease.currentStepId,
60
+ currentStepAttempt: lease.currentStepAttempt,
61
+ connectionId: record.authorizedTool?.connection.id,
62
+ provider,
63
+ toolId,
64
+ method: record.method,
65
+ outcome: record.outcome,
66
+ argumentSummary: summarizeIntegrationToolArguments(record.arguments),
67
+ },
68
+ 'integration tool call audited',
69
+ );
70
+ };
71
+ }
72
+
73
+ export function summarizeIntegrationToolArguments(value: unknown): IntegrationToolArgumentSummary {
74
+ return {
75
+ keys: isRecord(value) ? Object.keys(value).sort() : [],
76
+ serializedSizeBytes: serializedSize(value),
77
+ };
78
+ }
79
+
80
+ function serializedSize(value: unknown): number {
81
+ try {
82
+ return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');
83
+ } catch {
84
+ return 0;
85
+ }
86
+ }
87
+
88
+ function isRecord(value: unknown): value is Record<string, unknown> {
89
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
90
+ }
@@ -0,0 +1,139 @@
1
+ import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
2
+ import {IntegrationProviderError} from '#core/errors.js';
3
+ import type {
4
+ AgentToolCatalogEntry,
5
+ AgentToolCatalogMethod,
6
+ AgentToolSession,
7
+ AgentToolsProvider,
8
+ } from '#core/providers/agent-tools.js';
9
+ import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
10
+ import type {IntegrationToolDispatcher, IntegrationToolDispatchInput} from './mcp-server.js';
11
+
12
+ export interface CreateIntegrationToolDispatcherParams {
13
+ registry: IntegrationProviderRegistry;
14
+ }
15
+
16
+ const timeoutErrorPattern = /timed?\s*out|timeout/i;
17
+ const credentialErrorNamePattern = /Token|Credential|Secret|AccessToken/;
18
+
19
+ export function createIntegrationToolDispatcher(
20
+ params: CreateIntegrationToolDispatcherParams,
21
+ ): IntegrationToolDispatcher {
22
+ return (input) => dispatchIntegrationToolCall({...input, registry: params.registry});
23
+ }
24
+
25
+ async function dispatchIntegrationToolCall(
26
+ input: IntegrationToolDispatchInput & {registry: IntegrationProviderRegistry},
27
+ ): Promise<CallToolResult> {
28
+ let session: AgentToolSession<CallToolResult> | undefined;
29
+
30
+ try {
31
+ const adapter = input.registry.getAdapter(
32
+ input.authorizedTool.integration.provider,
33
+ 'agent_tools',
34
+ ) as AgentToolsProvider<
35
+ typeof input.authorizedTool.connection,
36
+ unknown,
37
+ typeof input.authorizedTool.integration,
38
+ CallToolResult
39
+ >;
40
+ session = await adapter.openSession({
41
+ connection: input.authorizedTool.connection,
42
+ tools: [agentToolCatalogEntry(input)],
43
+ scope: input.authorizedTool.integration,
44
+ });
45
+
46
+ return await session.call({
47
+ toolId: input.authorizedTool.tool.id,
48
+ arguments: input.arguments,
49
+ });
50
+ } catch (error) {
51
+ return toolError(errorResult(error));
52
+ } finally {
53
+ await closeSession(session);
54
+ }
55
+ }
56
+
57
+ function agentToolCatalogEntry(input: IntegrationToolDispatchInput): AgentToolCatalogEntry {
58
+ const {tool, description, inputSchema, outputSchema} = input.authorizedTool;
59
+ return {
60
+ id: tool.id,
61
+ description,
62
+ sensitivity: tool.sensitivity,
63
+ sensitive: tool.sensitive,
64
+ requiredScope: tool.requiredScope,
65
+ inputSchema,
66
+ ...(outputSchema === undefined ? {} : {outputSchema}),
67
+ ...(tool.methods === undefined
68
+ ? {}
69
+ : {
70
+ methods: tool.methods.map(
71
+ (method): AgentToolCatalogMethod => ({
72
+ id: method.id,
73
+ description: method.description ?? method.token,
74
+ sensitivity: method.sensitivity,
75
+ sensitive: method.sensitive,
76
+ requiredScope: method.requiredScope,
77
+ }),
78
+ ),
79
+ }),
80
+ };
81
+ }
82
+
83
+ async function closeSession(session: {close?(): Promise<void>} | undefined): Promise<void> {
84
+ try {
85
+ await session?.close?.();
86
+ } catch {
87
+ // Cleanup must not mask the tool result returned to the runner.
88
+ }
89
+ }
90
+
91
+ function errorResult(error: unknown): {code: string; message: string} {
92
+ if (error instanceof IntegrationProviderError) {
93
+ return {
94
+ code: error.reason,
95
+ message: `Integration provider error: ${error.reason}`,
96
+ };
97
+ }
98
+
99
+ if (isTimeoutError(error)) {
100
+ return {
101
+ code: 'provider-timeout',
102
+ message: 'Integration provider timed out',
103
+ };
104
+ }
105
+
106
+ if (isCredentialError(error)) {
107
+ return {
108
+ code: 'credentials-unavailable',
109
+ message: 'Integration provider credentials are unavailable',
110
+ };
111
+ }
112
+
113
+ return {
114
+ code: 'provider-unavailable',
115
+ message: 'Integration provider call failed',
116
+ };
117
+ }
118
+
119
+ function isTimeoutError(error: unknown): boolean {
120
+ if (!(error instanceof Error)) return false;
121
+ return (
122
+ error.name === 'AbortError' ||
123
+ timeoutErrorPattern.test(error.name) ||
124
+ timeoutErrorPattern.test(error.message)
125
+ );
126
+ }
127
+
128
+ function isCredentialError(error: unknown): boolean {
129
+ if (!(error instanceof Error)) return false;
130
+ return credentialErrorNamePattern.test(error.name);
131
+ }
132
+
133
+ function toolError(params: {code: string; message: string}): CallToolResult {
134
+ return {
135
+ isError: true,
136
+ content: [{type: 'text', text: params.message}],
137
+ structuredContent: {code: params.code},
138
+ };
139
+ }