@shipfox/api-agent-access 20.2.0 → 20.3.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 (38) 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 +16 -0
  5. package/dist/core/paged-tools.d.ts +15 -0
  6. package/dist/core/paged-tools.d.ts.map +1 -0
  7. package/dist/core/paged-tools.js +454 -0
  8. package/dist/core/paged-tools.js.map +1 -0
  9. package/dist/core/response.d.ts +22 -0
  10. package/dist/core/response.d.ts.map +1 -0
  11. package/dist/core/response.js +75 -0
  12. package/dist/core/response.js.map +1 -0
  13. package/dist/core/tools.d.ts +2 -0
  14. package/dist/core/tools.d.ts.map +1 -1
  15. package/dist/core/tools.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/presentation/mcp-server.d.ts.map +1 -1
  21. package/dist/presentation/mcp-server.js +23 -3
  22. package/dist/presentation/mcp-server.js.map +1 -1
  23. package/dist/presentation/routes.d.ts +10 -0
  24. package/dist/presentation/routes.d.ts.map +1 -1
  25. package/dist/presentation/routes.js +20 -3
  26. package/dist/presentation/routes.js.map +1 -1
  27. package/dist/tsconfig.test.tsbuildinfo +1 -1
  28. package/package.json +31 -14
  29. package/src/core/paged-tools.test.ts +519 -0
  30. package/src/core/paged-tools.ts +657 -0
  31. package/src/core/response.test.ts +65 -0
  32. package/src/core/response.ts +101 -0
  33. package/src/core/tools.ts +2 -0
  34. package/src/index.ts +11 -0
  35. package/src/presentation/mcp-server.test.ts +58 -0
  36. package/src/presentation/mcp-server.ts +25 -3
  37. package/src/presentation/routes.ts +37 -1
  38. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,65 @@
1
+ import {agentAccessSuccess} from './envelope.js';
2
+ import {
3
+ reducePagedAgentAccessResponse,
4
+ serializedAgentAccessEnvelopeByteLength,
5
+ truncateAgentAccessUtf8,
6
+ } from './response.js';
7
+
8
+ describe('agent-access response bounds', () => {
9
+ test('truncates on a complete UTF-8 code-point boundary', () => {
10
+ const result = truncateAgentAccessUtf8('🙂'.repeat(4), 7);
11
+
12
+ expect(result).toEqual({value: '🙂', truncated: true, totalBytes: 16});
13
+ });
14
+
15
+ test('regenerates a page cursor from the last retained item', () => {
16
+ const items = Array.from({length: 20}, (_, index) => ({
17
+ id: `item-${index}-${'x'.repeat(20)}`,
18
+ }));
19
+ const envelope = agentAccessSuccess({items, next_cursor: 'producer-cursor'});
20
+ const initialBytes = serializedAgentAccessEnvelopeByteLength(envelope);
21
+ const reduced = reducePagedAgentAccessResponse({
22
+ envelope,
23
+ itemKey: 'items',
24
+ items,
25
+ cursorForItem: (item) => `cursor:${String(item.id)}`,
26
+ maxBytes: initialBytes - 20,
27
+ });
28
+ if (!reduced.ok) throw new Error('Expected a reduced page');
29
+ const result = reduced.result as {items: Array<{id: string}>; next_cursor: string | null};
30
+ const last = result.items.at(-1);
31
+
32
+ expect(reduced).toMatchObject({
33
+ response_truncated: true,
34
+ response_total_bytes: initialBytes,
35
+ });
36
+ expect(result.items.length).toBeLessThan(items.length);
37
+ expect(last).toBeDefined();
38
+ expect(result.next_cursor).toBe(`cursor:${last?.id}`);
39
+ expect(serializedAgentAccessEnvelopeByteLength(reduced)).toBeLessThanOrEqual(initialBytes - 20);
40
+ });
41
+
42
+ test('returns a content-too-large error instead of dropping every item', () => {
43
+ const envelope = agentAccessSuccess({
44
+ items: [{id: 'x'.repeat(1_000)}],
45
+ next_cursor: null,
46
+ });
47
+ const initialBytes = serializedAgentAccessEnvelopeByteLength(envelope);
48
+ const emptyTruncatedEnvelope = {
49
+ ...envelope,
50
+ result: {items: [], next_cursor: null},
51
+ response_truncated: true,
52
+ response_total_bytes: initialBytes,
53
+ };
54
+
55
+ expect(
56
+ reducePagedAgentAccessResponse({
57
+ envelope,
58
+ itemKey: 'items',
59
+ items: [{id: 'x'.repeat(1_000)}],
60
+ cursorForItem: () => 'cursor',
61
+ maxBytes: serializedAgentAccessEnvelopeByteLength(emptyTruncatedEnvelope),
62
+ }),
63
+ ).toEqual({ok: false, error: {code: 'content-too-large'}});
64
+ });
65
+ });
@@ -0,0 +1,101 @@
1
+ import {
2
+ AGENT_ACCESS_RESPONSE_MAX_BYTES,
3
+ type AgentAccessEnvelopeDto,
4
+ } from '@shipfox/api-agent-access-dto';
5
+ import {agentAccessError} from './envelope.js';
6
+
7
+ const utf8Encoder = new TextEncoder();
8
+
9
+ export interface AgentAccessUtf8Truncation {
10
+ value: string;
11
+ truncated: boolean;
12
+ totalBytes: number;
13
+ }
14
+
15
+ export function truncateAgentAccessUtf8(
16
+ value: string,
17
+ maxBytes: number,
18
+ ): AgentAccessUtf8Truncation {
19
+ const totalBytes = utf8Encoder.encode(value).byteLength;
20
+ if (totalBytes <= maxBytes) return {value, truncated: false, totalBytes};
21
+ if (maxBytes <= 0) return {value: '', truncated: true, totalBytes};
22
+
23
+ let bytes = 0;
24
+ let result = '';
25
+ for (const codePoint of value) {
26
+ const codePointBytes = utf8Encoder.encode(codePoint).byteLength;
27
+ if (bytes + codePointBytes > maxBytes) break;
28
+ result += codePoint;
29
+ bytes += codePointBytes;
30
+ }
31
+
32
+ return {value: result, truncated: true, totalBytes};
33
+ }
34
+
35
+ export function serializedAgentAccessEnvelopeByteLength(envelope: AgentAccessEnvelopeDto): number {
36
+ const serialized = JSON.stringify(envelope);
37
+ if (serialized === undefined) throw new Error('Agent-access envelope is not serializable');
38
+ return utf8Encoder.encode(serialized).byteLength;
39
+ }
40
+
41
+ export interface ReducePagedAgentAccessResponseParams {
42
+ envelope: AgentAccessEnvelopeDto;
43
+ itemKey: string;
44
+ items: readonly Record<string, unknown>[];
45
+ cursorForItem: (item: Record<string, unknown>, index: number) => string;
46
+ maxBytes?: number | undefined;
47
+ }
48
+
49
+ /**
50
+ * Fits a paged success response without reusing a producer cursor that points past dropped rows.
51
+ * The cursor is always rebuilt from the final retained item.
52
+ */
53
+ export function reducePagedAgentAccessResponse(
54
+ params: ReducePagedAgentAccessResponseParams,
55
+ ): AgentAccessEnvelopeDto {
56
+ const maxBytes = params.maxBytes ?? AGENT_ACCESS_RESPONSE_MAX_BYTES;
57
+ const initialBytes = serializedAgentAccessEnvelopeByteLength(params.envelope);
58
+ if (initialBytes <= maxBytes) return params.envelope;
59
+ if (!params.envelope.ok || !isRecord(params.envelope.result)) {
60
+ return agentAccessError('content-too-large');
61
+ }
62
+
63
+ const itemCounts =
64
+ params.items.length === 0
65
+ ? [0]
66
+ : Array.from(
67
+ {length: Math.max(0, params.items.length - 1)},
68
+ (_, index) => params.items.length - index - 1,
69
+ );
70
+ for (const itemCount of itemCounts) {
71
+ const retained = params.items.slice(0, itemCount);
72
+ const last = retained.at(-1);
73
+ const nextCursor = last === undefined ? null : params.cursorForItem(last, itemCount - 1);
74
+ const candidate: AgentAccessEnvelopeDto = {
75
+ ...params.envelope,
76
+ result: {
77
+ ...params.envelope.result,
78
+ [params.itemKey]: retained,
79
+ next_cursor: nextCursor,
80
+ },
81
+ response_truncated: true,
82
+ response_total_bytes: initialBytes,
83
+ };
84
+ if (serializedAgentAccessEnvelopeByteLength(candidate) <= maxBytes) return candidate;
85
+ }
86
+
87
+ return agentAccessError('content-too-large');
88
+ }
89
+
90
+ export function fitAgentAccessResponseToCeiling(
91
+ envelope: AgentAccessEnvelopeDto,
92
+ maxBytes = AGENT_ACCESS_RESPONSE_MAX_BYTES,
93
+ ): AgentAccessEnvelopeDto {
94
+ return serializedAgentAccessEnvelopeByteLength(envelope) <= maxBytes
95
+ ? envelope
96
+ : agentAccessError('content-too-large');
97
+ }
98
+
99
+ function isRecord(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
101
+ }
package/src/core/tools.ts CHANGED
@@ -17,8 +17,10 @@ export interface AgentAccessTool {
17
17
  description: string;
18
18
  inputSchema: AgentAccessObjectSchema;
19
19
  outputSchema: AgentAccessObjectSchema;
20
+ validateInput?: ((input: unknown) => boolean) | undefined;
20
21
  annotations: {readonly readOnlyHint: true};
21
22
  execute: (call: AgentAccessToolCall) => Promise<AgentAccessEnvelopeDto> | AgentAccessEnvelopeDto;
23
+ validateResult?: ((result: unknown) => boolean) | undefined;
22
24
  }
23
25
 
24
26
  export type AgentAccessToolMap = ReadonlyMap<string, AgentAccessTool>;
package/src/index.ts CHANGED
@@ -13,12 +13,23 @@ export {
13
13
  parseAgentAccessEnvelope,
14
14
  serializeAgentAccessEnvelope,
15
15
  } from '#core/envelope.js';
16
+ export {
17
+ type AgentAccessPagedToolsOptions,
18
+ createAgentAccessTools,
19
+ } from '#core/paged-tools.js';
16
20
  export {
17
21
  type AgentAccessRateLimitDecision,
18
22
  type AgentAccessRateLimiter,
19
23
  type CreateAgentAccessRateLimiterOptions,
20
24
  createAgentAccessRateLimiter,
21
25
  } from '#core/rate-limiter.js';
26
+ export {
27
+ type AgentAccessUtf8Truncation,
28
+ fitAgentAccessResponseToCeiling,
29
+ reducePagedAgentAccessResponse,
30
+ serializedAgentAccessEnvelopeByteLength,
31
+ truncateAgentAccessUtf8,
32
+ } from '#core/response.js';
22
33
  export {
23
34
  type AgentAccessTool,
24
35
  type AgentAccessToolCall,
@@ -1,9 +1,15 @@
1
1
  import {Client} from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
3
3
  import {CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js';
4
+ import type {AnnotationsInterModuleClient} from '@shipfox/annotations-dto/inter-module';
4
5
  import {agentAccessEnvelopeSchema} from '@shipfox/api-agent-access-dto';
5
6
  import type {AgentAccessContext} from '@shipfox/api-auth-context';
7
+ import type {DefinitionsInterModuleClient} from '@shipfox/api-definitions-dto/inter-module';
8
+ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
9
+ import type {TriggersInterModuleClient} from '@shipfox/api-triggers-dto/inter-module';
10
+ import type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';
6
11
  import {agentAccessSuccess} from '#core/envelope.js';
12
+ import {createAgentAccessTools} from '#core/paged-tools.js';
7
13
  import {createAgentAccessRateLimiter} from '#core/rate-limiter.js';
8
14
  import {createAgentAccessFixtureTool} from '#core/tools.js';
9
15
  import {AGENT_ACCESS_PACKAGE_VERSION} from '#version.js';
@@ -71,6 +77,35 @@ describe('buildAgentAccessMcpServer', () => {
71
77
  ]);
72
78
  });
73
79
 
80
+ test('rejects multibyte input at the MCP boundary before calling a producer', async () => {
81
+ const listWorkflowRuns = vi.fn();
82
+ const tool = createAgentAccessTools({
83
+ projects: {} as unknown as ProjectsModuleClient,
84
+ definitions: {} as unknown as DefinitionsInterModuleClient,
85
+ workflows: {listWorkflowRuns} as unknown as WorkflowsModuleClient,
86
+ annotations: {} as unknown as AnnotationsInterModuleClient,
87
+ triggers: {} as unknown as TriggersInterModuleClient,
88
+ }).find((candidate) => candidate.name === 'list_workflow_runs');
89
+ if (!tool) throw new Error('Expected list_workflow_runs tool');
90
+
91
+ const {client, close} = await connectClient(createAgentAccessRateLimiter(), [tool]);
92
+ const result = await client.callTool(
93
+ {
94
+ name: 'list_workflow_runs',
95
+ arguments: {
96
+ project_id: '00000000-0000-4000-8000-000000000001',
97
+ trigger_source: '🙂'.repeat(129),
98
+ },
99
+ },
100
+ CallToolResultSchema,
101
+ );
102
+ await close();
103
+
104
+ expect(result.isError).toBe(true);
105
+ expect(result.structuredContent).toEqual({ok: false, error: {code: 'invalid-request'}});
106
+ expect(listWorkflowRuns).not.toHaveBeenCalled();
107
+ });
108
+
74
109
  test('records only the exception when serializing a tool result fails', async () => {
75
110
  const recordCall = vi.fn();
76
111
  const fixture = createAgentAccessFixtureTool();
@@ -125,6 +160,29 @@ describe('buildAgentAccessMcpServer', () => {
125
160
  ]);
126
161
  });
127
162
 
163
+ test('converts an oversized unpaged success into a bounded content-too-large error', async () => {
164
+ const fixture = createAgentAccessFixtureTool();
165
+ const oversizedTool = {
166
+ ...fixture,
167
+ name: 'oversized_fixture',
168
+ execute: () => agentAccessSuccess({message: 'x'.repeat(128 * 1024)}),
169
+ };
170
+ const {client, close} = await connectClient(createAgentAccessRateLimiter(), [oversizedTool]);
171
+
172
+ const result = await client.callTool(
173
+ {name: 'oversized_fixture', arguments: {message: 'ignored'}},
174
+ CallToolResultSchema,
175
+ );
176
+ await close();
177
+
178
+ expect(result.isError).toBe(true);
179
+ expect(result.structuredContent).toEqual({
180
+ ok: false,
181
+ error: {code: 'content-too-large'},
182
+ });
183
+ expect(agentAccessEnvelopeSchema.safeParse(result.structuredContent).success).toBe(true);
184
+ });
185
+
128
186
  test('does not count tool discovery against the credential window', async () => {
129
187
  const limiter = createAgentAccessRateLimiter({limit: 1, now: () => 1_000});
130
188
  const {client, close} = await connectClient(limiter);
@@ -11,6 +11,7 @@ import {logger} from '@shipfox/node-opentelemetry';
11
11
  import {AGENT_ACCESS_MCP_INSTRUCTIONS, AGENT_ACCESS_MCP_SERVER_NAME} from '#constants.js';
12
12
  import {agentAccessError, serializeAgentAccessEnvelope} from '#core/envelope.js';
13
13
  import {type AgentAccessRateLimiter, createAgentAccessRateLimiter} from '#core/rate-limiter.js';
14
+ import {fitAgentAccessResponseToCeiling} from '#core/response.js';
14
15
  import {
15
16
  type AgentAccessTool,
16
17
  type AgentAccessToolMap,
@@ -124,6 +125,16 @@ async function executeAgentAccessTool(params: {
124
125
  recordCall: AgentAccessToolCallRecorder;
125
126
  }): Promise<CallToolResult> {
126
127
  try {
128
+ if (params.tool.validateInput?.(params.input) === false) {
129
+ recordToolCall(params.recordCall, {
130
+ tool: params.tool.name,
131
+ outcome: 'invalid-request',
132
+ errorCode: 'invalid-request',
133
+ context: params.context,
134
+ });
135
+ return toolResult(agentAccessError('invalid-request'), true);
136
+ }
137
+
127
138
  const response = await params.tool.execute({context: params.context, arguments: params.input});
128
139
  const envelope = agentAccessEnvelopeSchema.safeParse(response);
129
140
  if (!envelope.success) {
@@ -136,12 +147,23 @@ async function executeAgentAccessTool(params: {
136
147
  return toolResult(agentAccessError('invalid-tool-response'), true);
137
148
  }
138
149
 
139
- const outcome: AgentAccessToolCallOutcome = envelope.data.ok ? 'success' : 'tool-error';
140
- const result = toolResult(envelope.data, !envelope.data.ok);
150
+ if (envelope.data.ok && params.tool.validateResult?.(envelope.data.result) === false) {
151
+ recordToolCall(params.recordCall, {
152
+ tool: params.tool.name,
153
+ outcome: 'exception',
154
+ errorCode: 'invalid-tool-response',
155
+ context: params.context,
156
+ });
157
+ return toolResult(agentAccessError('invalid-tool-response'), true);
158
+ }
159
+
160
+ const boundedEnvelope = fitAgentAccessResponseToCeiling(envelope.data);
161
+ const outcome: AgentAccessToolCallOutcome = boundedEnvelope.ok ? 'success' : 'tool-error';
162
+ const result = toolResult(boundedEnvelope, !boundedEnvelope.ok);
141
163
  recordToolCall(params.recordCall, {
142
164
  tool: params.tool.name,
143
165
  outcome,
144
- errorCode: envelope.data.ok ? 'none' : (envelope.data.error?.code ?? 'unknown'),
166
+ errorCode: boundedEnvelope.ok ? 'none' : (boundedEnvelope.error?.code ?? 'unknown'),
145
167
  context: params.context,
146
168
  });
147
169
  return result;
@@ -1,6 +1,11 @@
1
1
  import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
2
2
  import type {Transport} from '@modelcontextprotocol/sdk/shared/transport.js';
3
+ import type {AnnotationsInterModuleClient} from '@shipfox/annotations-dto/inter-module';
3
4
  import {AUTH_AGENT_ACCESS, requireAgentAccessContext} from '@shipfox/api-auth-context';
5
+ import type {DefinitionsInterModuleClient} from '@shipfox/api-definitions-dto/inter-module';
6
+ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
7
+ import type {TriggersInterModuleClient} from '@shipfox/api-triggers-dto/inter-module';
8
+ import type {WorkflowsModuleClient} from '@shipfox/api-workflows-dto/inter-module';
4
9
  import {reportError} from '@shipfox/node-error-monitoring';
5
10
  import {
6
11
  ClientError,
@@ -14,6 +19,7 @@ import {
14
19
  } from '@shipfox/node-fastify';
15
20
  import {logger} from '@shipfox/node-opentelemetry';
16
21
  import {AGENT_ACCESS_MCP_PATH, AGENT_ACCESS_PROTECTED_RESOURCE_METADATA_PATH} from '#constants.js';
22
+ import {createAgentAccessTools} from '#core/paged-tools.js';
17
23
  import {type AgentAccessRateLimiter, createAgentAccessRateLimiter} from '#core/rate-limiter.js';
18
24
  import {type AgentAccessTool, createAgentAccessFixtureTool} from '#core/tools.js';
19
25
  import {recordAgentAccessAuthFailure} from '#metrics/index.js';
@@ -29,10 +35,15 @@ export interface CreateAgentAccessRoutesOptions {
29
35
  rateLimiter?: AgentAccessRateLimiter | undefined;
30
36
  recordCall?: AgentAccessToolCallRecorder | undefined;
31
37
  isOriginAllowed?: ((origin: string | undefined) => boolean) | undefined;
38
+ projects?: ProjectsModuleClient | undefined;
39
+ definitions?: DefinitionsInterModuleClient | undefined;
40
+ workflows?: WorkflowsModuleClient | undefined;
41
+ annotations?: AnnotationsInterModuleClient | undefined;
42
+ triggers?: TriggersInterModuleClient | undefined;
32
43
  }
33
44
 
34
45
  export function createAgentAccessRoutes(options: CreateAgentAccessRoutesOptions = {}): RouteGroup {
35
- const tools = options.tools ?? [createAgentAccessFixtureTool()];
46
+ const tools = options.tools ?? toolsFromProducerClients(options);
36
47
  const rateLimiter = options.rateLimiter ?? createAgentAccessRateLimiter();
37
48
  const recordCall = options.recordCall ?? createAgentAccessToolCallRecorder();
38
49
  const originMatcher = options.isOriginAllowed ?? createAllowedOriginMatcher();
@@ -107,6 +118,31 @@ export function createAgentAccessRoutes(options: CreateAgentAccessRoutesOptions
107
118
  };
108
119
  }
109
120
 
121
+ function toolsFromProducerClients(
122
+ options: CreateAgentAccessRoutesOptions,
123
+ ): readonly AgentAccessTool[] {
124
+ const {projects, definitions, workflows, annotations, triggers} = options;
125
+ if (
126
+ projects === undefined &&
127
+ definitions === undefined &&
128
+ workflows === undefined &&
129
+ annotations === undefined &&
130
+ triggers === undefined
131
+ ) {
132
+ return [createAgentAccessFixtureTool()];
133
+ }
134
+ if (
135
+ projects === undefined ||
136
+ definitions === undefined ||
137
+ workflows === undefined ||
138
+ annotations === undefined ||
139
+ triggers === undefined
140
+ ) {
141
+ throw new Error('Agent-access producer clients must be configured together');
142
+ }
143
+ return createAgentAccessTools({projects, definitions, workflows, annotations, triggers});
144
+ }
145
+
110
146
  function methodNotAllowed(_request: FastifyRequest, reply: FastifyReply) {
111
147
  return reply.code(405).header('allow', 'POST').send({code: 'method-not-allowed'});
112
148
  }