@tmcp/transport-http 0.8.6 → 0.9.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/README.md +60 -19
- package/package.json +6 -6
- package/src/index.js +684 -134
- package/src/request-headers.js +251 -0
- package/src/types/index.d.ts +24 -1
- package/src/types/index.d.ts.map +3 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { HEADER_MISMATCH, McpError } from 'tmcp';
|
|
2
|
+
|
|
3
|
+
const ENCODED_PREFIX = '=?base64?';
|
|
4
|
+
const ENCODED_SUFFIX = '?=';
|
|
5
|
+
const HTTP_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
6
|
+
const PLAIN_HEADER_VALUE = /^[\t\x20-\x7e]*$/;
|
|
7
|
+
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
8
|
+
|
|
9
|
+
/** @param {string} message */
|
|
10
|
+
function header_mismatch(message) {
|
|
11
|
+
throw new McpError(HEADER_MISMATCH, `Header mismatch: ${message}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Decode an MCP mirrored header value and reject unsafe plain values or a
|
|
16
|
+
* malformed Base64 sentinel.
|
|
17
|
+
* @param {string} value
|
|
18
|
+
* @param {string} name
|
|
19
|
+
*/
|
|
20
|
+
function decode_value(value, name) {
|
|
21
|
+
if (value.startsWith(ENCODED_PREFIX) && value.endsWith(ENCODED_SUFFIX)) {
|
|
22
|
+
const encoded = value.slice(
|
|
23
|
+
ENCODED_PREFIX.length,
|
|
24
|
+
-ENCODED_SUFFIX.length,
|
|
25
|
+
);
|
|
26
|
+
try {
|
|
27
|
+
const decoded = atob(encoded);
|
|
28
|
+
if (btoa(decoded) !== encoded) {
|
|
29
|
+
throw new TypeError('Non-canonical Base64');
|
|
30
|
+
}
|
|
31
|
+
const bytes = Uint8Array.from(decoded, (character) =>
|
|
32
|
+
character.charCodeAt(0),
|
|
33
|
+
);
|
|
34
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
35
|
+
} catch {
|
|
36
|
+
header_mismatch(`${name} contains malformed Base64`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!PLAIN_HEADER_VALUE.test(value) || value.trim() !== value) {
|
|
40
|
+
header_mismatch(`${name} contains an invalid plain header value`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {Headers} headers
|
|
47
|
+
* @param {string} name
|
|
48
|
+
*/
|
|
49
|
+
function required_header(headers, name) {
|
|
50
|
+
const value = headers.get(name);
|
|
51
|
+
if (value === null) header_mismatch(`${name} is required`);
|
|
52
|
+
return /** @type {string} */ (value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate the standard headers mirrored from a per-request JSON-RPC
|
|
57
|
+
* request or notification.
|
|
58
|
+
* @param {Headers} headers
|
|
59
|
+
* @param {Record<string, any>} message
|
|
60
|
+
*/
|
|
61
|
+
export function validate_request_headers(headers, message) {
|
|
62
|
+
const protocol_version = required_header(headers, 'MCP-Protocol-Version');
|
|
63
|
+
const body_version =
|
|
64
|
+
message.params?._meta?.['io.modelcontextprotocol/protocolVersion'];
|
|
65
|
+
if (protocol_version !== body_version) {
|
|
66
|
+
header_mismatch(
|
|
67
|
+
`MCP-Protocol-Version does not match the request protocol version`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const method = required_header(headers, 'Mcp-Method');
|
|
72
|
+
if (method !== message.method) {
|
|
73
|
+
header_mismatch(`Mcp-Method does not match the request method`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const name_field =
|
|
77
|
+
message.method === 'resources/read'
|
|
78
|
+
? 'uri'
|
|
79
|
+
: message.method === 'tools/call' ||
|
|
80
|
+
message.method === 'prompts/get'
|
|
81
|
+
? 'name'
|
|
82
|
+
: undefined;
|
|
83
|
+
if (name_field === undefined) return;
|
|
84
|
+
const name = required_header(headers, 'Mcp-Name');
|
|
85
|
+
const decoded_name = decode_value(name, 'Mcp-Name');
|
|
86
|
+
if (
|
|
87
|
+
typeof message.params?.[name_field] !== 'string' ||
|
|
88
|
+
decoded_name !== message.params[name_field]
|
|
89
|
+
) {
|
|
90
|
+
header_mismatch(`Mcp-Name does not match params.${name_field}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {unknown} value
|
|
96
|
+
* @param {WeakSet<object>} [seen]
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function contains_annotation(value, seen = new WeakSet()) {
|
|
100
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
101
|
+
if (seen.has(value)) return false;
|
|
102
|
+
seen.add(value);
|
|
103
|
+
if (Object.hasOwn(value, 'x-mcp-header')) return true;
|
|
104
|
+
return Object.values(value).some((child) =>
|
|
105
|
+
contains_annotation(child, seen),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @typedef {{ name: string, path: string[], type: 'string' | 'boolean' | 'integer' }} ParameterHeader
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {Record<string, unknown>} schema
|
|
115
|
+
* @returns {ParameterHeader[]}
|
|
116
|
+
*/
|
|
117
|
+
function parameter_headers(schema) {
|
|
118
|
+
/** @type {ParameterHeader[]} */
|
|
119
|
+
const annotations = [];
|
|
120
|
+
const names = new Set();
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {unknown} value
|
|
124
|
+
* @param {string[]} path
|
|
125
|
+
*/
|
|
126
|
+
function visit(value, path) {
|
|
127
|
+
if (
|
|
128
|
+
typeof value !== 'object' ||
|
|
129
|
+
value === null ||
|
|
130
|
+
Array.isArray(value)
|
|
131
|
+
) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const node = /** @type {Record<string, unknown>} */ (value);
|
|
135
|
+
if (Object.hasOwn(node, 'x-mcp-header')) {
|
|
136
|
+
if (path.length === 0) {
|
|
137
|
+
throw new TypeError(
|
|
138
|
+
'Invalid x-mcp-header annotation: annotations must be attached to a property',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const name = node['x-mcp-header'];
|
|
142
|
+
const type = node.type;
|
|
143
|
+
if (typeof name !== 'string' || !HTTP_TOKEN.test(name)) {
|
|
144
|
+
throw new TypeError(
|
|
145
|
+
'Invalid x-mcp-header annotation: expected a non-empty HTTP token',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (type !== 'string' && type !== 'boolean' && type !== 'integer') {
|
|
149
|
+
throw new TypeError(
|
|
150
|
+
`Invalid x-mcp-header annotation ${JSON.stringify(name)}: expected a string, boolean, or integer property`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const normalized_name = name.toLowerCase();
|
|
154
|
+
if (names.has(normalized_name)) {
|
|
155
|
+
throw new TypeError(
|
|
156
|
+
`Invalid x-mcp-header annotation: duplicate name ${JSON.stringify(name)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
names.add(normalized_name);
|
|
160
|
+
annotations.push({ name, path, type });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const properties = node.properties;
|
|
164
|
+
if (
|
|
165
|
+
typeof properties === 'object' &&
|
|
166
|
+
properties !== null &&
|
|
167
|
+
!Array.isArray(properties)
|
|
168
|
+
) {
|
|
169
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
170
|
+
visit(child, [...path, key]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const [key, child] of Object.entries(node)) {
|
|
175
|
+
if (key === 'properties' || key === 'x-mcp-header') continue;
|
|
176
|
+
if (contains_annotation(child)) {
|
|
177
|
+
throw new TypeError(
|
|
178
|
+
'Invalid x-mcp-header annotation: annotations must be reachable only through properties',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
visit(schema, []);
|
|
185
|
+
return annotations;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {Record<string, unknown>} args
|
|
190
|
+
* @param {string[]} path
|
|
191
|
+
*/
|
|
192
|
+
function value_at_path(args, path) {
|
|
193
|
+
/** @type {unknown} */
|
|
194
|
+
let value = args;
|
|
195
|
+
for (const key of path) {
|
|
196
|
+
if (
|
|
197
|
+
typeof value !== 'object' ||
|
|
198
|
+
value === null ||
|
|
199
|
+
Array.isArray(value) ||
|
|
200
|
+
!Object.hasOwn(value, key)
|
|
201
|
+
) {
|
|
202
|
+
return { present: false, value: undefined };
|
|
203
|
+
}
|
|
204
|
+
value = /** @type {Record<string, unknown>} */ (value)[key];
|
|
205
|
+
}
|
|
206
|
+
return { present: true, value };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate recognized `Mcp-Param-*` headers against annotated tool
|
|
211
|
+
* parameters.
|
|
212
|
+
* @param {Headers} headers
|
|
213
|
+
* @param {Record<string, unknown>} input_schema
|
|
214
|
+
* @param {Record<string, unknown>} args
|
|
215
|
+
*/
|
|
216
|
+
export function validate_tool_parameter_headers(headers, input_schema, args) {
|
|
217
|
+
for (const annotation of parameter_headers(input_schema)) {
|
|
218
|
+
const header_name = `Mcp-Param-${annotation.name}`;
|
|
219
|
+
const header = headers.get(header_name);
|
|
220
|
+
const body = value_at_path(args, annotation.path);
|
|
221
|
+
if (!body.present || body.value === null) {
|
|
222
|
+
if (header !== null) {
|
|
223
|
+
header_mismatch(`${header_name} must be omitted`);
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (header === null) header_mismatch(`${header_name} is required`);
|
|
228
|
+
const decoded = decode_value(
|
|
229
|
+
/** @type {string} */ (header),
|
|
230
|
+
header_name,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
let matches = false;
|
|
234
|
+
if (annotation.type === 'string') {
|
|
235
|
+
matches = typeof body.value === 'string' && decoded === body.value;
|
|
236
|
+
} else if (annotation.type === 'boolean') {
|
|
237
|
+
matches =
|
|
238
|
+
typeof body.value === 'boolean' &&
|
|
239
|
+
decoded === (body.value ? 'true' : 'false');
|
|
240
|
+
} else if (
|
|
241
|
+
Number.isSafeInteger(body.value) &&
|
|
242
|
+
JSON_NUMBER.test(decoded)
|
|
243
|
+
) {
|
|
244
|
+
const number = Number(decoded);
|
|
245
|
+
matches = Number.isSafeInteger(number) && number === body.value;
|
|
246
|
+
}
|
|
247
|
+
if (!matches) {
|
|
248
|
+
header_mismatch(`${header_name} does not match the tool arguments`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/types/index.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
declare module '@tmcp/transport-http' {
|
|
2
2
|
import type { McpServer } from 'tmcp';
|
|
3
3
|
import type { OAuth } from '@tmcp/auth';
|
|
4
|
-
import type { StreamSessionManager, InfoSessionManager } from '@tmcp/session-manager';
|
|
4
|
+
import type { StreamSessionManager, InfoSessionManager, SubscriptionManager } from '@tmcp/session-manager';
|
|
5
5
|
export class HttpTransport<TCustom extends Record<string, unknown> | undefined = undefined> {
|
|
6
6
|
|
|
7
7
|
constructor(server: McpServer<any, TCustom>, options?: HttpTransportOptions);
|
|
8
|
+
/**
|
|
9
|
+
* Gracefully complete one active per-request subscription.
|
|
10
|
+
* */
|
|
11
|
+
closeSubscription(response: Response): Promise<boolean>;
|
|
12
|
+
/**
|
|
13
|
+
* Close every active per-request subscription owned by this transport.
|
|
14
|
+
*/
|
|
15
|
+
close(): Promise<void>;
|
|
8
16
|
|
|
9
17
|
respond(request: Request, ctx?: TCustom): Promise<Response | null>;
|
|
10
18
|
#private;
|
|
@@ -22,12 +30,27 @@ declare module '@tmcp/transport-http' {
|
|
|
22
30
|
path?: string | null;
|
|
23
31
|
oauth?: OAuth<"built">;
|
|
24
32
|
cors?: CorsConfig | boolean;
|
|
33
|
+
allowedOrigins?: string | string[] | true;
|
|
25
34
|
sessionManager?: {
|
|
26
35
|
streams?: StreamSessionManager;
|
|
27
36
|
info?: OptionalizeSessionManager<InfoSessionManager>;
|
|
28
37
|
};
|
|
38
|
+
subscriptionManager?: SubscriptionManager;
|
|
29
39
|
disableSse?: boolean;
|
|
30
40
|
};
|
|
41
|
+
export type SubscriptionRegistration = {
|
|
42
|
+
promise: Promise<boolean>;
|
|
43
|
+
resolve: (registered: boolean) => void;
|
|
44
|
+
};
|
|
45
|
+
export type SubscriptionSink = {
|
|
46
|
+
controller?: ReadableStreamDefaultController;
|
|
47
|
+
state: "open" | "cancelled" | "disconnected";
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
subscription?: {
|
|
50
|
+
id: string | number;
|
|
51
|
+
registration: SubscriptionRegistration;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
31
54
|
type ToOmit = 'removeSubscription';
|
|
32
55
|
|
|
33
56
|
type OptionalizeSessionManager<TInfoSessionManager extends InfoSessionManager> = Omit<TInfoSessionManager, ToOmit> & Partial<Pick<TInfoSessionManager, ToOmit>>;
|
package/src/types/index.d.ts.map
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
"file": "index.d.ts",
|
|
4
4
|
"names": [
|
|
5
5
|
"HttpTransport",
|
|
6
|
+
"SubscriptionRegistration",
|
|
7
|
+
"SubscriptionSink",
|
|
6
8
|
"ToOmit",
|
|
7
9
|
"OptionalizeSessionManager"
|
|
8
10
|
],
|
|
@@ -14,6 +16,6 @@
|
|
|
14
16
|
null,
|
|
15
17
|
null
|
|
16
18
|
],
|
|
17
|
-
"mappings": ";;;;
|
|
19
|
+
"mappings": ";;;;cAgEaA,aAAaA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aAhC0DC,wBAAwBA;;;;aACuGC,gBAAgBA;;;;;;;;;MC9B9NC,MAAMA;;MAECC,yBAAyBA",
|
|
18
20
|
"ignoreList": []
|
|
19
21
|
}
|