@shipfox/api-agent-access 20.2.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +17 -0
- package/dist/constants.js.map +1 -0
- package/dist/core/envelope.d.ts +9 -0
- package/dist/core/envelope.d.ts.map +1 -0
- package/dist/core/envelope.js +33 -0
- package/dist/core/envelope.js.map +1 -0
- package/dist/core/rate-limiter.d.ts +18 -0
- package/dist/core/rate-limiter.d.ts.map +1 -0
- package/dist/core/rate-limiter.js +67 -0
- package/dist/core/rate-limiter.js.map +1 -0
- package/dist/core/tools.d.ts +21 -0
- package/dist/core/tools.d.ts.map +1 -0
- package/dist/core/tools.js +60 -0
- package/dist/core/tools.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +8 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +26 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/module.d.ts +7 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +12 -0
- package/dist/module.js.map +1 -0
- package/dist/presentation/audit.d.ts +15 -0
- package/dist/presentation/audit.d.ts.map +1 -0
- package/dist/presentation/audit.js +28 -0
- package/dist/presentation/audit.js.map +1 -0
- package/dist/presentation/mcp-server.d.ts +13 -0
- package/dist/presentation/mcp-server.d.ts.map +1 -0
- package/dist/presentation/mcp-server.js +171 -0
- package/dist/presentation/mcp-server.js.map +1 -0
- package/dist/presentation/routes.d.ts +14 -0
- package/dist/presentation/routes.d.ts.map +1 -0
- package/dist/presentation/routes.js +149 -0
- package/dist/presentation/routes.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +50 -0
- package/src/constants.ts +22 -0
- package/src/core/envelope.test.ts +9 -0
- package/src/core/envelope.ts +36 -0
- package/src/core/rate-limiter.test.ts +62 -0
- package/src/core/rate-limiter.ts +95 -0
- package/src/core/tools.test.ts +38 -0
- package/src/core/tools.ts +67 -0
- package/src/index.ts +54 -0
- package/src/metrics/index.ts +1 -0
- package/src/metrics/instance.test.ts +67 -0
- package/src/metrics/instance.ts +48 -0
- package/src/module.ts +19 -0
- package/src/presentation/audit.test.ts +67 -0
- package/src/presentation/audit.ts +45 -0
- package/src/presentation/mcp-server.test.ts +168 -0
- package/src/presentation/mcp-server.ts +212 -0
- package/src/presentation/routes.test.ts +193 -0
- package/src/presentation/routes.ts +171 -0
- package/src/version.ts +5 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {AgentAccessContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
3
|
+
import {type AgentAccessToolCallOutcome, recordAgentAccessToolCall} from '#metrics/index.js';
|
|
4
|
+
|
|
5
|
+
export interface AgentAccessToolCallAuditRecord {
|
|
6
|
+
tool: string;
|
|
7
|
+
outcome: AgentAccessToolCallOutcome;
|
|
8
|
+
errorCode: string;
|
|
9
|
+
context: AgentAccessContext;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AgentAccessToolCallRecorder = (record: AgentAccessToolCallAuditRecord) => void;
|
|
13
|
+
|
|
14
|
+
export interface CreateAgentAccessToolCallRecorderOptions {
|
|
15
|
+
recordMetric?: typeof recordAgentAccessToolCall;
|
|
16
|
+
logInfo?:
|
|
17
|
+
| ((context: Record<string, unknown>, message: 'agent access tool call audited') => void)
|
|
18
|
+
| undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createAgentAccessToolCallRecorder(
|
|
22
|
+
options: CreateAgentAccessToolCallRecorderOptions = {},
|
|
23
|
+
): AgentAccessToolCallRecorder {
|
|
24
|
+
const recordMetric = options.recordMetric ?? recordAgentAccessToolCall;
|
|
25
|
+
const logInfo = options.logInfo ?? ((context, message) => logger().info(context, message));
|
|
26
|
+
|
|
27
|
+
return (record) => {
|
|
28
|
+
recordMetric({tool: record.tool, outcome: record.outcome});
|
|
29
|
+
logInfo(auditLogContext(record), 'agent access tool call audited');
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function auditLogContext(record: AgentAccessToolCallAuditRecord): Record<string, unknown> {
|
|
34
|
+
const credential = record.context.credential;
|
|
35
|
+
return {
|
|
36
|
+
tool: record.tool,
|
|
37
|
+
outcome: record.outcome,
|
|
38
|
+
errorCode: record.errorCode,
|
|
39
|
+
userId: record.context.userId,
|
|
40
|
+
workspaceId: record.context.workspaceId,
|
|
41
|
+
credentialKind: credential.kind,
|
|
42
|
+
credentialId: credential.kind === 'oauth_grant' ? credential.grantId : credential.patId,
|
|
43
|
+
clientId: credential.kind === 'oauth_grant' ? credential.clientId : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
|
3
|
+
import {CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import {agentAccessEnvelopeSchema} from '@shipfox/api-agent-access-dto';
|
|
5
|
+
import type {AgentAccessContext} from '@shipfox/api-auth-context';
|
|
6
|
+
import {agentAccessSuccess} from '#core/envelope.js';
|
|
7
|
+
import {createAgentAccessRateLimiter} from '#core/rate-limiter.js';
|
|
8
|
+
import {createAgentAccessFixtureTool} from '#core/tools.js';
|
|
9
|
+
import {AGENT_ACCESS_PACKAGE_VERSION} from '#version.js';
|
|
10
|
+
import {buildAgentAccessMcpServer} from './mcp-server.js';
|
|
11
|
+
|
|
12
|
+
const context: AgentAccessContext = {
|
|
13
|
+
userId: 'user-1',
|
|
14
|
+
workspaceId: 'workspace-1',
|
|
15
|
+
scopes: ['read'],
|
|
16
|
+
credential: {kind: 'pat', patId: 'pat-1'},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('buildAgentAccessMcpServer', () => {
|
|
20
|
+
test('lists only the fixture tool and returns a schema-valid serialized envelope', async () => {
|
|
21
|
+
const {client, close} = await connectClient();
|
|
22
|
+
|
|
23
|
+
const tools = await client.listTools();
|
|
24
|
+
const result = await client.callTool(
|
|
25
|
+
{name: 'agent_access_fixture', arguments: {message: 'hello'}},
|
|
26
|
+
CallToolResultSchema,
|
|
27
|
+
);
|
|
28
|
+
await close();
|
|
29
|
+
|
|
30
|
+
expect(client.getServerVersion()).toEqual({
|
|
31
|
+
name: 'shipfox',
|
|
32
|
+
version: AGENT_ACCESS_PACKAGE_VERSION,
|
|
33
|
+
});
|
|
34
|
+
expect(tools.tools).toHaveLength(1);
|
|
35
|
+
expect(tools.tools[0]).toMatchObject({
|
|
36
|
+
name: 'agent_access_fixture',
|
|
37
|
+
annotations: {readOnlyHint: true},
|
|
38
|
+
outputSchema: {type: 'object'},
|
|
39
|
+
});
|
|
40
|
+
expect(tools.tools[0]?.outputSchema).not.toHaveProperty('oneOf');
|
|
41
|
+
expect(result.isError).not.toBe(true);
|
|
42
|
+
expect(agentAccessEnvelopeSchema.safeParse(result.structuredContent).success).toBe(true);
|
|
43
|
+
expect(result.content).toEqual([
|
|
44
|
+
{type: 'text', text: JSON.stringify(result.structuredContent)},
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('returns a tool error with retry metadata without raising a JSON-RPC error', async () => {
|
|
49
|
+
const limiter = createAgentAccessRateLimiter({limit: 1, now: () => 1_000});
|
|
50
|
+
const {client, close} = await connectClient(limiter);
|
|
51
|
+
|
|
52
|
+
await client.listTools();
|
|
53
|
+
const first = await client.callTool(
|
|
54
|
+
{name: 'agent_access_fixture', arguments: {message: 'first'}},
|
|
55
|
+
CallToolResultSchema,
|
|
56
|
+
);
|
|
57
|
+
const second = await client.callTool(
|
|
58
|
+
{name: 'agent_access_fixture', arguments: {message: 'second'}},
|
|
59
|
+
CallToolResultSchema,
|
|
60
|
+
);
|
|
61
|
+
await close();
|
|
62
|
+
|
|
63
|
+
expect(first.isError).not.toBe(true);
|
|
64
|
+
expect(second.isError).toBe(true);
|
|
65
|
+
expect(second.structuredContent).toEqual({
|
|
66
|
+
ok: false,
|
|
67
|
+
error: {code: 'rate-limited', retry_after_seconds: 60},
|
|
68
|
+
});
|
|
69
|
+
expect(second.content).toEqual([
|
|
70
|
+
{type: 'text', text: JSON.stringify(second.structuredContent)},
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('records only the exception when serializing a tool result fails', async () => {
|
|
75
|
+
const recordCall = vi.fn();
|
|
76
|
+
const fixture = createAgentAccessFixtureTool();
|
|
77
|
+
const unserializableTool = {
|
|
78
|
+
...fixture,
|
|
79
|
+
name: 'unserializable_fixture',
|
|
80
|
+
execute: () => agentAccessSuccess({value: BigInt(1)}),
|
|
81
|
+
};
|
|
82
|
+
const {client, close} = await connectClient(
|
|
83
|
+
createAgentAccessRateLimiter(),
|
|
84
|
+
[unserializableTool],
|
|
85
|
+
recordCall,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const result = await client.callTool(
|
|
89
|
+
{name: 'unserializable_fixture', arguments: {message: 'ignored'}},
|
|
90
|
+
CallToolResultSchema,
|
|
91
|
+
);
|
|
92
|
+
await close();
|
|
93
|
+
|
|
94
|
+
expect(result.isError).toBe(true);
|
|
95
|
+
expect(result.structuredContent).toEqual({ok: false, error: {code: 'tool-failed'}});
|
|
96
|
+
expect(recordCall).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(recordCall).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
tool: 'unserializable_fixture',
|
|
100
|
+
outcome: 'exception',
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('returns schema-valid tool errors with the serialized envelope duplicate', async () => {
|
|
106
|
+
const {client, close} = await connectClient();
|
|
107
|
+
|
|
108
|
+
const result = await client.callTool(
|
|
109
|
+
{name: 'agent_access_fixture', arguments: {message: 123}},
|
|
110
|
+
CallToolResultSchema,
|
|
111
|
+
);
|
|
112
|
+
await close();
|
|
113
|
+
|
|
114
|
+
expect(result.isError).toBe(true);
|
|
115
|
+
expect(agentAccessEnvelopeSchema.safeParse(result.structuredContent).success).toBe(true);
|
|
116
|
+
expect(result.structuredContent).toEqual({
|
|
117
|
+
ok: false,
|
|
118
|
+
error: {
|
|
119
|
+
code: 'invalid-request',
|
|
120
|
+
message: 'message must be a string of at most 256 characters with no extra properties',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(result.content).toEqual([
|
|
124
|
+
{type: 'text', text: JSON.stringify(result.structuredContent)},
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('does not count tool discovery against the credential window', async () => {
|
|
129
|
+
const limiter = createAgentAccessRateLimiter({limit: 1, now: () => 1_000});
|
|
130
|
+
const {client, close} = await connectClient(limiter);
|
|
131
|
+
|
|
132
|
+
await client.listTools();
|
|
133
|
+
const result = await client.callTool(
|
|
134
|
+
{name: 'agent_access_fixture', arguments: {message: 'discovery is free'}},
|
|
135
|
+
CallToolResultSchema,
|
|
136
|
+
);
|
|
137
|
+
await close();
|
|
138
|
+
|
|
139
|
+
expect(result.isError).not.toBe(true);
|
|
140
|
+
expect(result.structuredContent).toMatchObject({ok: true});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
async function connectClient(
|
|
145
|
+
rateLimiter = createAgentAccessRateLimiter(),
|
|
146
|
+
tools = [createAgentAccessFixtureTool()],
|
|
147
|
+
recordCall?: Parameters<typeof buildAgentAccessMcpServer>[0]['recordCall'],
|
|
148
|
+
): Promise<{client: Client; close: () => Promise<void>}> {
|
|
149
|
+
const server = buildAgentAccessMcpServer({
|
|
150
|
+
context,
|
|
151
|
+
tools,
|
|
152
|
+
rateLimiter,
|
|
153
|
+
recordCall,
|
|
154
|
+
});
|
|
155
|
+
const client = new Client({name: 'test-client', version: '0.0.0'});
|
|
156
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
157
|
+
|
|
158
|
+
await server.connect(serverTransport);
|
|
159
|
+
await client.connect(clientTransport);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
client,
|
|
163
|
+
close: async () => {
|
|
164
|
+
await client.close();
|
|
165
|
+
await server.close();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {Server} from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import {
|
|
3
|
+
CallToolRequestSchema,
|
|
4
|
+
type CallToolResult,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import {agentAccessEnvelopeSchema} from '@shipfox/api-agent-access-dto';
|
|
8
|
+
import type {AgentAccessContext} from '@shipfox/api-auth-context';
|
|
9
|
+
import {reportError} from '@shipfox/node-error-monitoring';
|
|
10
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
11
|
+
import {AGENT_ACCESS_MCP_INSTRUCTIONS, AGENT_ACCESS_MCP_SERVER_NAME} from '#constants.js';
|
|
12
|
+
import {agentAccessError, serializeAgentAccessEnvelope} from '#core/envelope.js';
|
|
13
|
+
import {type AgentAccessRateLimiter, createAgentAccessRateLimiter} from '#core/rate-limiter.js';
|
|
14
|
+
import {
|
|
15
|
+
type AgentAccessTool,
|
|
16
|
+
type AgentAccessToolMap,
|
|
17
|
+
createAgentAccessFixtureTool,
|
|
18
|
+
createAgentAccessToolMap,
|
|
19
|
+
} from '#core/tools.js';
|
|
20
|
+
import type {AgentAccessToolCallOutcome} from '#metrics/index.js';
|
|
21
|
+
import {AGENT_ACCESS_PACKAGE_VERSION} from '#version.js';
|
|
22
|
+
import {type AgentAccessToolCallRecorder, createAgentAccessToolCallRecorder} from './audit.js';
|
|
23
|
+
|
|
24
|
+
export interface BuildAgentAccessMcpServerParams {
|
|
25
|
+
context: AgentAccessContext;
|
|
26
|
+
tools?: readonly AgentAccessTool[] | undefined;
|
|
27
|
+
rateLimiter?: AgentAccessRateLimiter | undefined;
|
|
28
|
+
recordCall?: AgentAccessToolCallRecorder | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultTools = (): readonly AgentAccessTool[] => [createAgentAccessFixtureTool()];
|
|
32
|
+
|
|
33
|
+
export function buildAgentAccessMcpServer(params: BuildAgentAccessMcpServerParams): Server {
|
|
34
|
+
const tools = createAgentAccessToolMap(params.tools ?? defaultTools());
|
|
35
|
+
const rateLimiter = params.rateLimiter ?? createAgentAccessRateLimiter();
|
|
36
|
+
const recordCall = params.recordCall ?? createAgentAccessToolCallRecorder();
|
|
37
|
+
const server = new Server(
|
|
38
|
+
{name: AGENT_ACCESS_MCP_SERVER_NAME, version: AGENT_ACCESS_PACKAGE_VERSION},
|
|
39
|
+
{
|
|
40
|
+
capabilities: {tools: {}},
|
|
41
|
+
instructions: AGENT_ACCESS_MCP_INSTRUCTIONS,
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
46
|
+
tools: [...tools.values()].map((tool) => ({
|
|
47
|
+
name: tool.name,
|
|
48
|
+
description: tool.description,
|
|
49
|
+
inputSchema: tool.inputSchema as {
|
|
50
|
+
type: 'object';
|
|
51
|
+
properties?: Record<string, object> | undefined;
|
|
52
|
+
required?: string[] | undefined;
|
|
53
|
+
},
|
|
54
|
+
outputSchema: tool.outputSchema as {
|
|
55
|
+
type: 'object';
|
|
56
|
+
properties?: Record<string, object> | undefined;
|
|
57
|
+
required?: string[] | undefined;
|
|
58
|
+
},
|
|
59
|
+
annotations: {readOnlyHint: true},
|
|
60
|
+
})),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
server.setRequestHandler(CallToolRequestSchema, (request) =>
|
|
64
|
+
handleAgentAccessToolCall({
|
|
65
|
+
name: request.params.name,
|
|
66
|
+
arguments: request.params.arguments,
|
|
67
|
+
context: params.context,
|
|
68
|
+
tools,
|
|
69
|
+
rateLimiter,
|
|
70
|
+
recordCall,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return server;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface HandleAgentAccessToolCallParams {
|
|
78
|
+
name: string;
|
|
79
|
+
arguments?: Record<string, unknown> | undefined;
|
|
80
|
+
context: AgentAccessContext;
|
|
81
|
+
tools: AgentAccessToolMap;
|
|
82
|
+
rateLimiter: AgentAccessRateLimiter;
|
|
83
|
+
recordCall: AgentAccessToolCallRecorder;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleAgentAccessToolCall(
|
|
87
|
+
params: HandleAgentAccessToolCallParams,
|
|
88
|
+
): Promise<CallToolResult> {
|
|
89
|
+
const tool = params.tools.get(params.name);
|
|
90
|
+
const rateLimit = params.rateLimiter.consume(params.context.credential);
|
|
91
|
+
if (!rateLimit.allowed) {
|
|
92
|
+
recordToolCall(params.recordCall, {
|
|
93
|
+
tool: tool?.name ?? 'unknown',
|
|
94
|
+
outcome: 'rate-limited',
|
|
95
|
+
errorCode: 'rate-limited',
|
|
96
|
+
context: params.context,
|
|
97
|
+
});
|
|
98
|
+
return toolResult(
|
|
99
|
+
agentAccessError(
|
|
100
|
+
'rate-limited',
|
|
101
|
+
rateLimit.retry_after_seconds === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: {retryAfterSeconds: rateLimit.retry_after_seconds},
|
|
104
|
+
),
|
|
105
|
+
true,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (tool === undefined) return unknownToolResult(params);
|
|
109
|
+
|
|
110
|
+
const input = params.arguments ?? {};
|
|
111
|
+
if (!isRecord(input)) return invalidArgumentsResult(params, tool.name);
|
|
112
|
+
return await executeAgentAccessTool({
|
|
113
|
+
tool,
|
|
114
|
+
input,
|
|
115
|
+
context: params.context,
|
|
116
|
+
recordCall: params.recordCall,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function executeAgentAccessTool(params: {
|
|
121
|
+
tool: AgentAccessTool;
|
|
122
|
+
input: Record<string, unknown>;
|
|
123
|
+
context: AgentAccessContext;
|
|
124
|
+
recordCall: AgentAccessToolCallRecorder;
|
|
125
|
+
}): Promise<CallToolResult> {
|
|
126
|
+
try {
|
|
127
|
+
const response = await params.tool.execute({context: params.context, arguments: params.input});
|
|
128
|
+
const envelope = agentAccessEnvelopeSchema.safeParse(response);
|
|
129
|
+
if (!envelope.success) {
|
|
130
|
+
recordToolCall(params.recordCall, {
|
|
131
|
+
tool: params.tool.name,
|
|
132
|
+
outcome: 'exception',
|
|
133
|
+
errorCode: 'invalid-tool-response',
|
|
134
|
+
context: params.context,
|
|
135
|
+
});
|
|
136
|
+
return toolResult(agentAccessError('invalid-tool-response'), true);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const outcome: AgentAccessToolCallOutcome = envelope.data.ok ? 'success' : 'tool-error';
|
|
140
|
+
const result = toolResult(envelope.data, !envelope.data.ok);
|
|
141
|
+
recordToolCall(params.recordCall, {
|
|
142
|
+
tool: params.tool.name,
|
|
143
|
+
outcome,
|
|
144
|
+
errorCode: envelope.data.ok ? 'none' : (envelope.data.error?.code ?? 'unknown'),
|
|
145
|
+
context: params.context,
|
|
146
|
+
});
|
|
147
|
+
return result;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
recordToolCall(params.recordCall, {
|
|
150
|
+
tool: params.tool.name,
|
|
151
|
+
outcome: 'exception',
|
|
152
|
+
errorCode: 'unknown',
|
|
153
|
+
context: params.context,
|
|
154
|
+
});
|
|
155
|
+
logger().error({err: error, tool: params.tool.name}, 'Agent-access tool execution failed');
|
|
156
|
+
reportError(error, {boundary: 'agent-access.mcp', operation: 'tool-call'});
|
|
157
|
+
return toolResult(agentAccessError('tool-failed'), true);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function unknownToolResult(params: HandleAgentAccessToolCallParams): CallToolResult {
|
|
162
|
+
recordToolCall(params.recordCall, {
|
|
163
|
+
tool: 'unknown',
|
|
164
|
+
outcome: 'invalid-request',
|
|
165
|
+
errorCode: 'unknown-tool',
|
|
166
|
+
context: params.context,
|
|
167
|
+
});
|
|
168
|
+
return toolResult(agentAccessError('unknown-tool', {message: 'Tool is not available'}), true);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function invalidArgumentsResult(
|
|
172
|
+
params: HandleAgentAccessToolCallParams,
|
|
173
|
+
toolName: string,
|
|
174
|
+
): CallToolResult {
|
|
175
|
+
recordToolCall(params.recordCall, {
|
|
176
|
+
tool: toolName,
|
|
177
|
+
outcome: 'invalid-request',
|
|
178
|
+
errorCode: 'invalid-request',
|
|
179
|
+
context: params.context,
|
|
180
|
+
});
|
|
181
|
+
return toolResult(
|
|
182
|
+
agentAccessError('invalid-request', {message: 'Tool arguments must be an object'}),
|
|
183
|
+
true,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function toolResult(
|
|
188
|
+
envelope: ReturnType<typeof agentAccessError>,
|
|
189
|
+
isError: boolean,
|
|
190
|
+
): CallToolResult {
|
|
191
|
+
return {
|
|
192
|
+
...(isError ? {isError: true} : {}),
|
|
193
|
+
content: [{type: 'text', text: serializeAgentAccessEnvelope(envelope)}],
|
|
194
|
+
structuredContent: envelope as Record<string, unknown>,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function recordToolCall(
|
|
199
|
+
recordCall: AgentAccessToolCallRecorder,
|
|
200
|
+
record: Parameters<AgentAccessToolCallRecorder>[0],
|
|
201
|
+
): void {
|
|
202
|
+
try {
|
|
203
|
+
recordCall(record);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
logger().error({err: error}, 'Failed to record agent-access tool audit event');
|
|
206
|
+
reportError(error, {boundary: 'agent-access.mcp', operation: 'audit'});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
211
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
212
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
type AgentAccessContext,
|
|
7
|
+
AUTH_AGENT_ACCESS,
|
|
8
|
+
setAgentAccessContext,
|
|
9
|
+
} from '@shipfox/api-auth-context';
|
|
10
|
+
import {
|
|
11
|
+
type AuthMethod,
|
|
12
|
+
ClientError,
|
|
13
|
+
closeApp,
|
|
14
|
+
createApp,
|
|
15
|
+
type FastifyRequest,
|
|
16
|
+
} from '@shipfox/node-fastify';
|
|
17
|
+
import {createAgentAccessRateLimiter} from '#core/rate-limiter.js';
|
|
18
|
+
import {createAgentAccessRoutes} from './routes.js';
|
|
19
|
+
|
|
20
|
+
const context: AgentAccessContext = {
|
|
21
|
+
userId: 'user-1',
|
|
22
|
+
workspaceId: 'workspace-1',
|
|
23
|
+
scopes: ['read'],
|
|
24
|
+
credential: {kind: 'oauth_grant', grantId: 'grant-1', clientId: 'client-1'},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let authCalls = 0;
|
|
28
|
+
|
|
29
|
+
const testAuth: AuthMethod = {
|
|
30
|
+
name: AUTH_AGENT_ACCESS,
|
|
31
|
+
authenticate: (request: FastifyRequest) => {
|
|
32
|
+
authCalls += 1;
|
|
33
|
+
if (request.headers.authorization !== 'Bearer valid-token') {
|
|
34
|
+
throw new ClientError('Missing or invalid Authorization header', 'unauthorized', {
|
|
35
|
+
status: 401,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
setAgentAccessContext(request, context);
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('agent-access MCP routes', () => {
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
await closeApp();
|
|
46
|
+
authCalls = 0;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await closeApp();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('returns 405 for allowed GET and DELETE requests', async () => {
|
|
54
|
+
const app = await createTestApp();
|
|
55
|
+
|
|
56
|
+
const getResponse = await app.inject({
|
|
57
|
+
method: 'GET',
|
|
58
|
+
url: '/mcp',
|
|
59
|
+
headers: {origin: 'https://allowed.example.test'},
|
|
60
|
+
});
|
|
61
|
+
const deleteResponse = await app.inject({
|
|
62
|
+
method: 'DELETE',
|
|
63
|
+
url: '/mcp',
|
|
64
|
+
headers: {origin: 'https://allowed.example.test'},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(getResponse.statusCode).toBe(405);
|
|
68
|
+
expect(deleteResponse.statusCode).toBe(405);
|
|
69
|
+
expect(getResponse.headers.allow).toBe('POST');
|
|
70
|
+
expect(deleteResponse.headers.allow).toBe('POST');
|
|
71
|
+
expect(authCalls).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('rejects a disallowed Origin before agent authentication', async () => {
|
|
75
|
+
const app = await createTestApp();
|
|
76
|
+
|
|
77
|
+
const response = await app.inject({
|
|
78
|
+
method: 'POST',
|
|
79
|
+
url: '/mcp',
|
|
80
|
+
headers: {origin: 'https://evil.example.test'},
|
|
81
|
+
payload: {},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(response.statusCode).toBe(403);
|
|
85
|
+
expect(response.json()).toEqual({code: 'origin-not-allowed'});
|
|
86
|
+
expect(authCalls).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('allows an Origin-less request to proceed to authentication', async () => {
|
|
90
|
+
const app = await createTestApp();
|
|
91
|
+
|
|
92
|
+
const response = await app.inject({
|
|
93
|
+
method: 'POST',
|
|
94
|
+
url: '/mcp',
|
|
95
|
+
headers: {authorization: 'Bearer valid-token'},
|
|
96
|
+
payload: {},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(response.statusCode).toBe(406);
|
|
100
|
+
expect(authCalls).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('challenges unauthenticated requests with protected-resource metadata', async () => {
|
|
104
|
+
const app = await createTestApp();
|
|
105
|
+
|
|
106
|
+
const response = await app.inject({
|
|
107
|
+
method: 'POST',
|
|
108
|
+
url: '/mcp',
|
|
109
|
+
headers: {origin: 'https://allowed.example.test'},
|
|
110
|
+
payload: {},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(response.statusCode).toBe(401);
|
|
114
|
+
expect(response.headers['www-authenticate']).toBe(
|
|
115
|
+
'Bearer scope="read", resource_metadata="https://api.example.test/.well-known/oauth-protected-resource"',
|
|
116
|
+
);
|
|
117
|
+
expect(response.json()).toEqual({code: 'unauthorized'});
|
|
118
|
+
expect(authCalls).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('serves stateless Streamable HTTP with the fixture tool', async () => {
|
|
122
|
+
const app = await createTestApp();
|
|
123
|
+
const address = await app.listen({port: 0, host: '127.0.0.1'});
|
|
124
|
+
const client = new Client({name: 'test-http-client', version: '0.0.0'});
|
|
125
|
+
const transport = new StreamableHTTPClientTransport(new URL('/mcp', address), {
|
|
126
|
+
requestInit: {headers: {authorization: 'Bearer valid-token'}},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await client.connect(transport as unknown as Transport);
|
|
130
|
+
const tools = await client.listTools();
|
|
131
|
+
const result = await client.callTool(
|
|
132
|
+
{name: 'agent_access_fixture', arguments: {message: 'hello over HTTP'}},
|
|
133
|
+
CallToolResultSchema,
|
|
134
|
+
);
|
|
135
|
+
await client.close();
|
|
136
|
+
|
|
137
|
+
expect(tools.tools.map((tool) => tool.name)).toEqual(['agent_access_fixture']);
|
|
138
|
+
expect(client.getServerVersion()?.name).toBe('shipfox');
|
|
139
|
+
expect(result.isError).not.toBe(true);
|
|
140
|
+
expect(result.structuredContent).toEqual({
|
|
141
|
+
ok: true,
|
|
142
|
+
result: {message: 'hello over HTTP'},
|
|
143
|
+
});
|
|
144
|
+
expect(result.content).toEqual([
|
|
145
|
+
{type: 'text', text: JSON.stringify(result.structuredContent)},
|
|
146
|
+
]);
|
|
147
|
+
expect(authCalls).toBeGreaterThan(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('returns an MCP tool error when the credential exceeds its window', async () => {
|
|
151
|
+
const app = await createTestApp(createAgentAccessRateLimiter({limit: 1, now: () => 1_000}));
|
|
152
|
+
const address = await app.listen({port: 0, host: '127.0.0.1'});
|
|
153
|
+
const client = new Client({name: 'test-http-client', version: '0.0.0'});
|
|
154
|
+
const transport = new StreamableHTTPClientTransport(new URL('/mcp', address), {
|
|
155
|
+
requestInit: {headers: {authorization: 'Bearer valid-token'}},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await client.connect(transport as unknown as Transport);
|
|
159
|
+
await client.listTools();
|
|
160
|
+
await client.callTool(
|
|
161
|
+
{name: 'agent_access_fixture', arguments: {message: 'first'}},
|
|
162
|
+
CallToolResultSchema,
|
|
163
|
+
);
|
|
164
|
+
const overLimit = await client.callTool(
|
|
165
|
+
{name: 'agent_access_fixture', arguments: {message: 'second'}},
|
|
166
|
+
CallToolResultSchema,
|
|
167
|
+
);
|
|
168
|
+
await client.close();
|
|
169
|
+
|
|
170
|
+
expect(overLimit.isError).toBe(true);
|
|
171
|
+
expect(overLimit.structuredContent).toEqual({
|
|
172
|
+
ok: false,
|
|
173
|
+
error: {code: 'rate-limited', retry_after_seconds: 60},
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
async function createTestApp(rateLimiter = createAgentAccessRateLimiter()) {
|
|
179
|
+
const app = await createApp({
|
|
180
|
+
auth: [testAuth],
|
|
181
|
+
routes: [
|
|
182
|
+
createAgentAccessRoutes({
|
|
183
|
+
apiPublicUrl: 'https://api.example.test',
|
|
184
|
+
isOriginAllowed: (origin) =>
|
|
185
|
+
origin === undefined || origin === 'https://allowed.example.test',
|
|
186
|
+
rateLimiter,
|
|
187
|
+
}),
|
|
188
|
+
],
|
|
189
|
+
swagger: false,
|
|
190
|
+
});
|
|
191
|
+
await app.ready();
|
|
192
|
+
return app;
|
|
193
|
+
}
|