spiceflow 1.6.1 → 1.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spiceflow",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "Simple API framework with RPC and type safety",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -45,6 +45,7 @@
45
45
  "ajv": "^8.17.1",
46
46
  "ajv-formats": "^3.0.1",
47
47
  "eventsource-parser": "^3.0.0",
48
+ "lodash.clonedeep": "^4.5.0",
48
49
  "openapi-types": "^12.1.3",
49
50
  "superjson": "^2.2.2",
50
51
  "zod": "^3.24.1",
@@ -59,7 +60,8 @@
59
60
  }
60
61
  },
61
62
  "devDependencies": {
62
- "@types/node": "22.12.0",
63
+ "@types/lodash.clonedeep": "^4.5.9",
64
+ "@types/node": "22.13.4",
63
65
  "eventsource": "^3.0.5",
64
66
  "formdata-node": "^6.0.3",
65
67
  "js-base64": "^3.7.7",
@@ -234,12 +234,19 @@ describe('client', () => {
234
234
 
235
235
  it('stream return', async () => {
236
236
  const { data } = await client['stream-return'].get()
237
- expect(data).toEqual('a')
237
+ let all = ''
238
+ for await (const chunk of data!) {
239
+ all += chunk
240
+ }
241
+ expect(all).toEqual('a')
238
242
  })
239
243
  it('stream return async', async () => {
240
- const { data } = await client['stream-return-async'].get({})
241
- // console.log(data)
242
- expect(data).toEqual('a')
244
+ const { data } = await client['stream-return-async'].get()
245
+ let all = ''
246
+ for await (const chunk of data!) {
247
+ all += chunk
248
+ }
249
+ expect(all).toEqual('a')
243
250
  })
244
251
  it('post zodAny', async () => {
245
252
  const body = [{ key: 'value' }, 123, 'string', true, null]
package/src/cors.ts CHANGED
@@ -27,7 +27,7 @@ export const cors = (options?: CORSOptions): MiddlewareHandler => {
27
27
  allowHeaders: [],
28
28
  exposeHeaders: [],
29
29
  credentials: true,
30
- cacheAge: 21600 // 6 hours default
30
+ cacheAge: 21600, // 6 hours default
31
31
  }
32
32
  const opts = {
33
33
  ...defaults,
@@ -49,7 +49,16 @@ export const cors = (options?: CORSOptions): MiddlewareHandler => {
49
49
  let response = await next()
50
50
 
51
51
  function set(key: string, value: string) {
52
- response.headers.set(key, value)
52
+ try {
53
+ response.headers.set(key, value)
54
+ } catch (error) {
55
+ if (error instanceof TypeError && error.message.includes('immutable')) {
56
+ response = new Response(response.body, response)
57
+ set(key, value)
58
+ } else {
59
+ throw error
60
+ }
61
+ }
53
62
  }
54
63
 
55
64
  const allowOrigin = findAllowOrigin(c.request.headers.get('origin') || '')
@@ -82,10 +91,13 @@ export const cors = (options?: CORSOptions): MiddlewareHandler => {
82
91
  if (opts.cacheAge && opts.cacheAge > 0) {
83
92
  // CORS preflight cache
84
93
  set('Access-Control-Max-Age', opts.cacheAge.toString())
85
-
94
+
86
95
  // Browser cache and CDN cache
87
- set('Cache-Control', `public, max-age=${opts.cacheAge}, s-maxage=${opts.cacheAge}`)
88
-
96
+ set(
97
+ 'Cache-Control',
98
+ `public, max-age=${opts.cacheAge}, s-maxage=${opts.cacheAge}`,
99
+ )
100
+
89
101
  // Additional CDN-specific headers
90
102
  set('CDN-Cache-Control', `public, s-maxage=${opts.cacheAge}`)
91
103
  set('Cloudflare-CDN-Cache-Control', `public, s-maxage=${opts.cacheAge}`)
package/src/error.ts CHANGED
@@ -11,8 +11,8 @@ const env =
11
11
  export const ERROR_CODE = Symbol('SpiceflowErrorCode')
12
12
  export type ERROR_CODE = typeof ERROR_CODE
13
13
 
14
- export const ELYSIA_RESPONSE = Symbol('SpiceflowResponse')
15
- export type ELYSIA_RESPONSE = typeof ELYSIA_RESPONSE
14
+ export const SPICEFLOW_RESPONSE = Symbol('SpiceflowResponse')
15
+ export type SPICEFLOW_RESPONSE = typeof SPICEFLOW_RESPONSE
16
16
 
17
17
  export const isProduction = (env?.NODE_ENV ?? env?.ENV) === 'production'
18
18
 
package/src/mcp.test.ts CHANGED
@@ -85,7 +85,6 @@ describe('MCP Plugin', () => {
85
85
  "description": "GET /goSomething",
86
86
  "inputSchema": {
87
87
  "properties": {},
88
- "required": [],
89
88
  "type": "object",
90
89
  },
91
90
  "name": "GET /goSomething",
@@ -94,18 +93,15 @@ describe('MCP Plugin', () => {
94
93
  "description": "GET /users",
95
94
  "inputSchema": {
96
95
  "properties": {},
97
- "required": [],
98
96
  "type": "object",
99
97
  },
100
98
  "name": "GET /users",
101
99
  },
102
100
  {
103
- "description": "GET /somethingElse/:id",
101
+ "description": "GET /somethingElse/{id}",
104
102
  "inputSchema": {
105
103
  "properties": {
106
104
  "params": {
107
- "$schema": "http://json-schema.org/draft-07/schema#",
108
- "additionalProperties": false,
109
105
  "properties": {
110
106
  "id": {
111
107
  "type": "string",
@@ -117,24 +113,20 @@ describe('MCP Plugin', () => {
117
113
  "type": "object",
118
114
  },
119
115
  },
120
- "required": [],
121
116
  "type": "object",
122
117
  },
123
- "name": "GET /somethingElse/:id",
118
+ "name": "GET /somethingElse/{id}",
124
119
  },
125
120
  {
126
121
  "description": "GET /search",
127
122
  "inputSchema": {
128
123
  "properties": {
129
124
  "query": {
130
- "$schema": "http://json-schema.org/draft-07/schema#",
131
- "additionalProperties": false,
132
125
  "properties": {
133
126
  "limit": {
134
127
  "type": "number",
135
128
  },
136
129
  "q": {
137
- "description": "Search query",
138
130
  "type": "string",
139
131
  },
140
132
  },
@@ -145,7 +137,6 @@ describe('MCP Plugin', () => {
145
137
  "type": "object",
146
138
  },
147
139
  },
148
- "required": [],
149
140
  "type": "object",
150
141
  },
151
142
  "name": "GET /search",
@@ -195,12 +186,12 @@ describe('MCP Plugin', () => {
195
186
  {
196
187
  "mimeType": "application/json",
197
188
  "name": "GET /goSomething",
198
- "uri": "http://localhost:4000/goSomething",
189
+ "uri": "http://localhost/goSomething",
199
190
  },
200
191
  {
201
192
  "mimeType": "application/json",
202
193
  "name": "GET /users",
203
- "uri": "http://localhost:4000/users",
194
+ "uri": "http://localhost/users",
204
195
  },
205
196
  ]
206
197
  `)
package/src/mcp.ts CHANGED
@@ -10,6 +10,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema'
10
10
  import { SSEServerTransportSpiceflow } from './mcp-transport.js'
11
11
  import { isZodSchema, Spiceflow } from './spiceflow.js'
12
12
  import { OpenAPIV3 } from 'openapi-types'
13
+ import { openapi } from './openapi.js'
13
14
 
14
15
  function getJsonSchema(schema: any) {
15
16
  if (!schema) return undefined
@@ -20,12 +21,75 @@ function getJsonSchema(schema: any) {
20
21
  }
21
22
  return schema
22
23
  }
24
+ const transports = new Map<string, SSEServerTransportSpiceflow>()
25
+ function getOperationRequestBody(
26
+ operation: OpenAPIV3.OperationObject,
27
+ ): OpenAPIV3.SchemaObject | undefined {
28
+ if (!operation.requestBody) return undefined
29
+
30
+ const requestBody = operation.requestBody as OpenAPIV3.RequestBodyObject
31
+ const content = requestBody.content['application/json']
32
+ return content?.schema as OpenAPIV3.SchemaObject
33
+ }
23
34
 
24
- export const mcp = <Path extends string = '/mcp'>({
25
- path = '/mcp' as Path,
35
+ function getOperationParameters(operation: OpenAPIV3.OperationObject): {
36
+ queryParams?: OpenAPIV3.SchemaObject
37
+ pathParams?: OpenAPIV3.SchemaObject
38
+ } {
39
+ if (!operation.parameters) return {}
40
+
41
+ const queryProperties: Record<string, OpenAPIV3.SchemaObject> = {}
42
+ const pathProperties: Record<string, OpenAPIV3.SchemaObject> = {}
43
+ const queryRequired: string[] = []
44
+ const pathRequired: string[] = []
45
+
46
+ operation.parameters.forEach((param) => {
47
+ if ('$ref' in param) return // TODO referenced parameters
48
+
49
+ if (param.in === 'query') {
50
+ queryProperties[param.name] = param.schema as OpenAPIV3.SchemaObject
51
+ if (param.required) queryRequired.push(param.name)
52
+ } else if (param.in === 'path') {
53
+ pathProperties[param.name] = param.schema as OpenAPIV3.SchemaObject
54
+ if (param.required) pathRequired.push(param.name)
55
+ }
56
+ })
57
+
58
+ const result: {
59
+ queryParams?: OpenAPIV3.SchemaObject
60
+ pathParams?: OpenAPIV3.SchemaObject
61
+ } = {}
62
+
63
+ if (Object.keys(queryProperties).length > 0) {
64
+ result.queryParams = {
65
+ type: 'object',
66
+ properties: queryProperties,
67
+ required: queryRequired.length > 0 ? queryRequired : undefined,
68
+ }
69
+ }
70
+
71
+ if (Object.keys(pathProperties).length > 0) {
72
+ result.pathParams = {
73
+ type: 'object',
74
+ properties: pathProperties,
75
+ required: pathRequired.length > 0 ? pathRequired : undefined,
76
+ }
77
+ }
78
+
79
+ return result
80
+ }
81
+
82
+ function createMCPServer({
26
83
  name = 'spiceflow',
27
84
  version = '1.0.0',
28
- } = {}) => {
85
+ openapi,
86
+ app,
87
+ }: {
88
+ name?: string
89
+ version?: string
90
+ openapi: OpenAPIV3.Document
91
+ app: Spiceflow
92
+ }) {
29
93
  const server = new Server(
30
94
  { name, version },
31
95
  {
@@ -36,213 +100,234 @@ export const mcp = <Path extends string = '/mcp'>({
36
100
  },
37
101
  )
38
102
 
39
- const transports = new Map<string, SSEServerTransportSpiceflow>()
40
- const messagePath = path + '/message'
41
- let app = new Spiceflow({ name: 'mcp' })
42
- .post(messagePath, async ({ request, query }) => {
43
- const sessionId = query.sessionId!
44
-
45
- const t = transports.get(sessionId)
46
- if (!t) {
47
- return new Response('Session not found', { status: 404 })
48
- }
49
-
50
- await t.handlePostMessage(request)
51
- return 'ok'
52
- })
53
- .get(path, async ({ request }) => {
54
- const transport = new SSEServerTransportSpiceflow(messagePath)
55
- transports.set(transport.sessionId, transport)
56
- server.onclose = () => {
57
- transports.delete(transport.sessionId)
58
- }
59
- await server.connect(transport)
60
-
61
- request.signal.addEventListener('abort', () => {
62
- transport.close().catch((error) => {
63
- console.error('Error closing transport:', error)
64
- })
65
- })
66
-
67
- if (request.method === 'POST') {
68
- return await transport.handlePostMessage(request)
69
- }
70
- let routes = app
71
- .getAllRoutes()
72
- .filter((x) => x.path !== path && x.path !== messagePath)
73
-
74
- server.setRequestHandler(ListToolsRequestSchema, async () => {
75
- return {
76
- tools: routes.map((route) => {
77
- const bodySchema = getJsonSchema(route.hooks?.body)
78
- const querySchema = getJsonSchema(route.hooks?.query)
79
- const paramsSchema = getJsonSchema(route.hooks?.params)
80
-
103
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
104
+ const tools = Object.entries(openapi.paths)
105
+ .filter(
106
+ ([path]) => !['/mcp-openapi', '/mcp', '/mcp/message'].includes(path),
107
+ )
108
+ .flatMap(([path, pathObj]) =>
109
+ Object.entries(pathObj || {})
110
+ .filter(([method]) => method !== 'parameters')
111
+ .map(([method, operation]) => {
81
112
  const properties: Record<string, any> = {}
82
113
  const required: string[] = []
83
114
 
84
- if (bodySchema) {
85
- properties.body = bodySchema
115
+ const requestBody = getOperationRequestBody(
116
+ operation as OpenAPIV3.OperationObject,
117
+ )
118
+ if (requestBody) {
119
+ properties.body = requestBody
86
120
  required.push('body')
87
121
  }
88
- if (querySchema?.properties) {
89
- properties.query = querySchema
122
+
123
+ const { queryParams, pathParams } = getOperationParameters(
124
+ operation as OpenAPIV3.OperationObject,
125
+ )
126
+ if (queryParams) {
127
+ properties.query = queryParams
90
128
  }
91
- if (paramsSchema?.properties) {
92
- properties.params = paramsSchema
129
+ if (pathParams) {
130
+ properties.params = pathParams
93
131
  }
94
132
 
95
133
  return {
96
- name: getRouteName({ method: route.method, path: route.path }),
97
-
134
+ name: getRouteName({ method, path }),
98
135
  description:
99
- route.hooks?.detail?.description ||
100
- `${route.method} ${route.path}`,
136
+ (operation as OpenAPIV3.OperationObject).description ||
137
+ (operation as OpenAPIV3.OperationObject).summary ||
138
+ `${method.toUpperCase()} ${path}`,
101
139
  inputSchema: {
102
140
  type: 'object',
103
141
  properties,
104
- required,
142
+ required: required.length > 0 ? required : undefined,
105
143
  },
106
144
  }
107
145
  }),
108
- }
109
- })
146
+ )
110
147
 
111
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
112
- const toolName = request.params.name
113
- let { path, method } = getPathFromToolName(toolName)
148
+ return { tools }
149
+ })
114
150
 
115
- const route = routes.find(
116
- (r) =>
117
- r.method.toUpperCase() === method.toUpperCase() && r.path === path,
118
- )
151
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
152
+ const toolName = request.params.name
153
+ let { path, method } = getPathFromToolName(toolName)
119
154
 
120
- if (!route) {
121
- return {
122
- content: [{ type: 'text', text: `Tool ${toolName} not found` }],
123
- isError: true,
124
- }
125
- }
126
-
127
- try {
128
- const { body, query, params } = request.params.arguments || {}
129
-
130
- if (params) {
131
- Object.entries(params).forEach(([key, value]) => {
132
- path = path.replace(`:${key}`, encodeURIComponent(String(value)))
133
- })
134
- }
135
- const url = new URL(`http://localhost${path}`)
136
- if (query) {
137
- Object.entries(query).forEach(([key, value]) => {
138
- url.searchParams.set(key, String(value))
139
- })
140
- }
141
-
142
- const response = await app.topLevelApp!.handle(
143
- new Request(url, {
144
- method: route.method,
145
- headers: {
146
- 'content-type': 'application/json',
147
- },
148
- body: body ? JSON.stringify(body) : undefined,
149
- }),
150
- )
155
+ const pathObj = openapi.paths[path]
156
+ if (!pathObj || !pathObj[method.toLowerCase()]) {
157
+ return {
158
+ content: [{ type: 'text', text: `Tool ${toolName} not found` }],
159
+ isError: true,
160
+ }
161
+ }
151
162
 
152
- const isError = !response.ok
163
+ try {
164
+ const { body, query, params } = request.params.arguments || {}
153
165
 
154
- const contentType = response.headers.get('content-type')
155
- if (contentType?.includes('application/json')) {
156
- const json = await response.json()
157
- return {
158
- isError,
159
- content: [{ type: 'text', text: JSON.stringify(json, null, 2) }],
160
- }
161
- }
162
-
163
- const text = await response.text()
164
- return {
165
- isError,
166
- content: [{ type: 'text', text }],
167
- }
168
- } catch (error: any) {
169
- return {
170
- content: [{ type: 'text', text: error.message || 'Unknown error' }],
171
- isError: true,
172
- }
173
- }
174
- })
175
- const resourcesRoutes = routes.filter((route) => {
176
- if (route.method !== 'GET') return false
166
+ if (params) {
167
+ Object.entries(params).forEach(([key, value]) => {
168
+ path = path.replace(`{${key}}`, encodeURIComponent(String(value)))
169
+ })
170
+ }
177
171
 
178
- if (route.path.includes(':')) return false
172
+ const url = new URL(`http://localhost${path}`)
173
+ if (query) {
174
+ Object.entries(query).forEach(([key, value]) => {
175
+ url.searchParams.set(key, String(value))
176
+ })
177
+ }
179
178
 
180
- const querySchema = route.hooks?.query
179
+ const response = await app.handle(
180
+ new Request(url, {
181
+ method: method,
182
+ headers: {
183
+ 'content-type': 'application/json',
184
+ },
185
+ body: body ? JSON.stringify(body) : undefined,
186
+ }),
187
+ )
188
+
189
+ const isError = !response.ok
190
+ const contentType = response.headers.get('content-type')
191
+
192
+ if (contentType?.includes('application/json')) {
193
+ const json = await response.json()
194
+ return {
195
+ isError,
196
+ content: [{ type: 'text', text: JSON.stringify(json, null, 2) }],
197
+ }
198
+ }
181
199
 
182
- if (querySchema) {
183
- const jsonSchema = getJsonSchema(querySchema)
184
- if (jsonSchema?.required?.length) {
185
- return false
186
- }
200
+ const text = await response.text()
201
+ return {
202
+ isError,
203
+ content: [{ type: 'text', text }],
204
+ }
205
+ } catch (error: any) {
206
+ return {
207
+ content: [{ type: 'text', text: error.message || 'Unknown error' }],
208
+ isError: true,
209
+ }
210
+ }
211
+ })
212
+
213
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
214
+ const resources: { uri: string; mimeType: string; name: string }[] = []
215
+ for (const [path, pathObj] of Object.entries(openapi.paths)) {
216
+ if (path.startsWith('/mcp')) {
217
+ continue
218
+ }
219
+ const getOperation = pathObj?.get as OpenAPIV3.OperationObject
220
+ if (getOperation && !path.includes('{')) {
221
+ const { queryParams } = getOperationParameters(getOperation)
222
+ const hasRequiredQuery =
223
+ queryParams?.required && queryParams.required.length > 0
224
+
225
+ if (!hasRequiredQuery) {
226
+ resources.push({
227
+ uri: new URL(path, 'http://localhost').href,
228
+ mimeType: 'application/json',
229
+ name: `GET ${path}`,
230
+ })
187
231
  }
232
+ }
233
+ }
234
+ return { resources }
235
+ })
236
+
237
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
238
+ const resourceUrl = new URL(request.params.uri)
239
+ const path = resourceUrl.pathname
240
+
241
+ const pathObj = openapi.paths[path]
242
+ if (!pathObj?.get) {
243
+ throw new Error('Resource not found')
244
+ }
245
+
246
+ const response = await app.handle(
247
+ new Request(resourceUrl, {
248
+ method: 'GET',
249
+ headers: {
250
+ 'content-type': 'application/json',
251
+ },
252
+ }),
253
+ )
254
+
255
+ const contentType = response.headers.get('content-type')
256
+ const text = await response.text()
257
+
258
+ if (contentType?.includes('application/json')) {
259
+ return {
260
+ contents: [
261
+ {
262
+ uri: request.params.uri,
263
+ mimeType: 'application/json',
264
+ text: text,
265
+ },
266
+ ],
267
+ }
268
+ }
269
+
270
+ return {
271
+ contents: [
272
+ {
273
+ uri: request.params.uri,
274
+ mimeType: 'text/plain',
275
+ text,
276
+ },
277
+ ],
278
+ }
279
+ })
280
+
281
+ return { server, transports }
282
+ }
188
283
 
189
- return true
190
- })
191
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
192
- const resources = resourcesRoutes.map((route) => ({
193
- uri: new URL(route.path, `http://${request.headers.get('host')}`)
194
- .href,
195
- mimeType: 'application/json',
196
- name: `GET ${route.path}`,
197
- }))
198
- return { resources }
199
- })
284
+ export const mcp = <Path extends string = '/mcp'>({
285
+ path = '/mcp' as Path,
286
+ name = 'spiceflow',
287
+ version = '1.0.0',
288
+ } = {}) => {
289
+ const messagePath = path + '/message'
200
290
 
201
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
202
- const resourceUrl = new URL(request.params.uri)
203
- const path = resourceUrl.pathname
291
+ let app = new Spiceflow({ name: 'mcp' })
292
+ .use(openapi({ path: '/mcp-openapi' }))
293
+ .post(messagePath, async ({ request, query }) => {
294
+ const sessionId = query.sessionId!
204
295
 
205
- const route = resourcesRoutes.find(
206
- (route) => route.path === path && route.method === 'GET',
207
- )
208
- if (!route) {
209
- throw new Error('Resource not found')
210
- }
296
+ const t = transports.get(sessionId)
297
+ if (!t) {
298
+ return new Response('Session not found', { status: 404 })
299
+ }
211
300
 
212
- const response = await app.topLevelApp!.handle(
213
- new Request(resourceUrl, {
214
- method: 'GET',
215
- headers: {
216
- 'content-type': 'application/json',
217
- },
218
- }),
219
- )
220
-
221
- const contentType = response.headers.get('content-type')
222
- const text = await response.text()
223
- if (contentType?.includes('application/json')) {
224
- return {
225
- contents: [
226
- {
227
- uri: request.params.uri,
228
- mimeType: 'application/json',
229
- text: text,
230
- },
231
- ],
232
- }
233
- }
301
+ await t.handlePostMessage(request)
302
+ return 'ok'
303
+ })
304
+ .get(path, async ({ request }) => {
305
+ const transport = new SSEServerTransportSpiceflow(messagePath)
306
+ transports.set(transport.sessionId, transport)
307
+ const openapi = await app
308
+ .topLevelApp!.handle(new Request('http://localhost/mcp-openapi'))
309
+ .then((r) => r.json())
310
+ const { server } = createMCPServer({
311
+ name,
312
+ version,
313
+ openapi,
314
+ app: app!.topLevelApp!,
315
+ })
316
+ server.onclose = () => {
317
+ transports.delete(transport.sessionId)
318
+ }
319
+ await server.connect(transport)
234
320
 
235
- return {
236
- contents: [
237
- {
238
- uri: request.params.uri,
239
- mimeType: 'text/plain',
240
- text,
241
- },
242
- ],
243
- }
321
+ request.signal.addEventListener('abort', () => {
322
+ transport.close().catch((error) => {
323
+ console.error('Error closing transport:', error)
324
+ })
244
325
  })
245
326
 
327
+ if (request.method === 'POST') {
328
+ return await transport.handlePostMessage(request)
329
+ }
330
+
246
331
  return transport.response
247
332
  })
248
333