@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,171 @@
|
|
|
1
|
+
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
2
|
+
import type {Transport} from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
3
|
+
import {AUTH_AGENT_ACCESS, requireAgentAccessContext} from '@shipfox/api-auth-context';
|
|
4
|
+
import {reportError} from '@shipfox/node-error-monitoring';
|
|
5
|
+
import {
|
|
6
|
+
ClientError,
|
|
7
|
+
createAllowedOriginMatcher,
|
|
8
|
+
errorHandler as defaultErrorHandler,
|
|
9
|
+
defineRoute,
|
|
10
|
+
type FastifyReply,
|
|
11
|
+
type FastifyRequest,
|
|
12
|
+
type RouteGroup,
|
|
13
|
+
type RoutePreHandler,
|
|
14
|
+
} from '@shipfox/node-fastify';
|
|
15
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
16
|
+
import {AGENT_ACCESS_MCP_PATH, AGENT_ACCESS_PROTECTED_RESOURCE_METADATA_PATH} from '#constants.js';
|
|
17
|
+
import {type AgentAccessRateLimiter, createAgentAccessRateLimiter} from '#core/rate-limiter.js';
|
|
18
|
+
import {type AgentAccessTool, createAgentAccessFixtureTool} from '#core/tools.js';
|
|
19
|
+
import {recordAgentAccessAuthFailure} from '#metrics/index.js';
|
|
20
|
+
import {type AgentAccessToolCallRecorder, createAgentAccessToolCallRecorder} from './audit.js';
|
|
21
|
+
import {buildAgentAccessMcpServer} from './mcp-server.js';
|
|
22
|
+
|
|
23
|
+
const TRAILING_SLASHES_RE = /\/+$/u;
|
|
24
|
+
|
|
25
|
+
export interface CreateAgentAccessRoutesOptions {
|
|
26
|
+
apiPublicUrl?: string | undefined;
|
|
27
|
+
protectedResourceMetadataUrl?: string | undefined;
|
|
28
|
+
tools?: readonly AgentAccessTool[] | undefined;
|
|
29
|
+
rateLimiter?: AgentAccessRateLimiter | undefined;
|
|
30
|
+
recordCall?: AgentAccessToolCallRecorder | undefined;
|
|
31
|
+
isOriginAllowed?: ((origin: string | undefined) => boolean) | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createAgentAccessRoutes(options: CreateAgentAccessRoutesOptions = {}): RouteGroup {
|
|
35
|
+
const tools = options.tools ?? [createAgentAccessFixtureTool()];
|
|
36
|
+
const rateLimiter = options.rateLimiter ?? createAgentAccessRateLimiter();
|
|
37
|
+
const recordCall = options.recordCall ?? createAgentAccessToolCallRecorder();
|
|
38
|
+
const originMatcher = options.isOriginAllowed ?? createAllowedOriginMatcher();
|
|
39
|
+
const errorHandler = createAgentAccessErrorHandler(resourceMetadataUrl(options));
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
prefix: '',
|
|
43
|
+
routes: [
|
|
44
|
+
defineRoute({
|
|
45
|
+
method: 'GET',
|
|
46
|
+
path: AGENT_ACCESS_MCP_PATH,
|
|
47
|
+
description: 'MCP endpoint does not provide an SSE GET stream.',
|
|
48
|
+
preAuth: createOriginGuard(originMatcher),
|
|
49
|
+
handler: methodNotAllowed,
|
|
50
|
+
}),
|
|
51
|
+
defineRoute({
|
|
52
|
+
method: 'DELETE',
|
|
53
|
+
path: AGENT_ACCESS_MCP_PATH,
|
|
54
|
+
description: 'MCP endpoint does not provide DELETE session operations.',
|
|
55
|
+
preAuth: createOriginGuard(originMatcher),
|
|
56
|
+
handler: methodNotAllowed,
|
|
57
|
+
}),
|
|
58
|
+
defineRoute({
|
|
59
|
+
method: 'POST',
|
|
60
|
+
path: AGENT_ACCESS_MCP_PATH,
|
|
61
|
+
description: 'Stateless Streamable HTTP MCP endpoint for agent-access tools.',
|
|
62
|
+
auth: AUTH_AGENT_ACCESS,
|
|
63
|
+
preAuth: createOriginGuard(originMatcher),
|
|
64
|
+
errorHandler,
|
|
65
|
+
handler: async (request, reply) => {
|
|
66
|
+
const context = requireAgentAccessContext(request);
|
|
67
|
+
const server = buildAgentAccessMcpServer({context, tools, rateLimiter, recordCall});
|
|
68
|
+
// No sessionIdGenerator selects the SDK's stateless transport mode.
|
|
69
|
+
const transport = new StreamableHTTPServerTransport();
|
|
70
|
+
let connected = false;
|
|
71
|
+
let cleanedUp = false;
|
|
72
|
+
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
if (cleanedUp) return;
|
|
75
|
+
cleanedUp = true;
|
|
76
|
+
void transport.close().catch((error) => {
|
|
77
|
+
logger().error({err: error}, 'Failed to close agent-access transport');
|
|
78
|
+
reportError(error, {
|
|
79
|
+
boundary: 'agent-access.mcp',
|
|
80
|
+
operation: 'close-transport',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
if (connected) {
|
|
84
|
+
void server.close().catch((error) => {
|
|
85
|
+
logger().error({err: error}, 'Failed to close agent-access server');
|
|
86
|
+
reportError(error, {
|
|
87
|
+
boundary: 'agent-access.mcp',
|
|
88
|
+
operation: 'close-server',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
reply.raw.once('close', cleanup);
|
|
95
|
+
try {
|
|
96
|
+
await server.connect(transport as unknown as Transport);
|
|
97
|
+
connected = true;
|
|
98
|
+
reply.hijack();
|
|
99
|
+
await transport.handleRequest(request.raw, reply.raw, request.body);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
cleanup();
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function methodNotAllowed(_request: FastifyRequest, reply: FastifyReply) {
|
|
111
|
+
return reply.code(405).header('allow', 'POST').send({code: 'method-not-allowed'});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function createOriginGuard(
|
|
115
|
+
isOriginAllowed: (origin: string | undefined) => boolean,
|
|
116
|
+
): RoutePreHandler {
|
|
117
|
+
return async (request, reply) => {
|
|
118
|
+
if (isOriginAllowed(request.headers.origin)) return;
|
|
119
|
+
recordAgentAccessAuthFailure('origin-not-allowed');
|
|
120
|
+
await reply.code(403).send({code: 'origin-not-allowed'});
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createAgentAccessErrorHandler(resourceMetadataUrl: string) {
|
|
125
|
+
const challenge = `Bearer scope="read", resource_metadata="${escapeHeaderValue(resourceMetadataUrl)}"`;
|
|
126
|
+
|
|
127
|
+
return (error: unknown, request: FastifyRequest, reply: FastifyReply) => {
|
|
128
|
+
const status = errorStatus(error);
|
|
129
|
+
const reason = authFailureReason(error, status, request);
|
|
130
|
+
if (reason !== undefined) recordAgentAccessAuthFailure(reason);
|
|
131
|
+
if (status === 401) reply.header('www-authenticate', challenge);
|
|
132
|
+
return defaultErrorHandler(error, request, reply);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resourceMetadataUrl(options: CreateAgentAccessRoutesOptions): string {
|
|
137
|
+
if (options.protectedResourceMetadataUrl !== undefined) {
|
|
138
|
+
return options.protectedResourceMetadataUrl;
|
|
139
|
+
}
|
|
140
|
+
if (options.apiPublicUrl === undefined) return AGENT_ACCESS_PROTECTED_RESOURCE_METADATA_PATH;
|
|
141
|
+
return `${options.apiPublicUrl.replace(TRAILING_SLASHES_RE, '')}${AGENT_ACCESS_PROTECTED_RESOURCE_METADATA_PATH}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function escapeHeaderValue(value: string): string {
|
|
145
|
+
return value.replace(/[\\"\r\n]/gu, (character) => {
|
|
146
|
+
if (character === '\r' || character === '\n') return '';
|
|
147
|
+
return `\\${character}`;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function errorStatus(error: unknown): number | undefined {
|
|
152
|
+
if (error instanceof ClientError) return error.status ?? 400;
|
|
153
|
+
if (isRecord(error) && typeof error.statusCode === 'number') return error.statusCode;
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function authFailureReason(
|
|
158
|
+
error: unknown,
|
|
159
|
+
status: number | undefined,
|
|
160
|
+
request: FastifyRequest,
|
|
161
|
+
): 'missing' | 'invalid' | 'dependency-unavailable' | undefined {
|
|
162
|
+
if (isRecord(error) && error.code === 'auth-dependency-unavailable') {
|
|
163
|
+
return 'dependency-unavailable';
|
|
164
|
+
}
|
|
165
|
+
if (status !== 401) return undefined;
|
|
166
|
+
return request.headers.authorization === undefined ? 'missing' : 'invalid';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
170
|
+
return typeof value === 'object' && value !== null;
|
|
171
|
+
}
|
package/src/version.ts
ADDED