@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.
@@ -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
+ }
@@ -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>>;
@@ -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": ";;;;cAuCaA,aAAaA;;;;;;;;;;;;;;;;;;;;;;;;;;MCpCrBC,MAAMA;;MAECC,yBAAyBA",
19
+ "mappings": ";;;;cAgEaA,aAAaA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aAhC0DC,wBAAwBA;;;;aACuGC,gBAAgBA;;;;;;;;;MC9B9NC,MAAMA;;MAECC,yBAAyBA",
18
20
  "ignoreList": []
19
21
  }