spiceflow 1.1.18 → 1.3.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.
Files changed (49) hide show
  1. package/README.md +245 -10
  2. package/dist/cors.d.ts +2 -0
  3. package/dist/cors.d.ts.map +1 -1
  4. package/dist/cors.js +10 -2
  5. package/dist/cors.js.map +1 -1
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-transport.d.ts +45 -0
  10. package/dist/mcp-transport.d.ts.map +1 -0
  11. package/dist/mcp-transport.js +107 -0
  12. package/dist/mcp-transport.js.map +1 -0
  13. package/dist/mcp.d.ts +36 -0
  14. package/dist/mcp.d.ts.map +1 -0
  15. package/dist/mcp.js +211 -0
  16. package/dist/mcp.js.map +1 -0
  17. package/dist/mcp.test.d.ts +2 -0
  18. package/dist/mcp.test.d.ts.map +1 -0
  19. package/dist/mcp.test.js +224 -0
  20. package/dist/mcp.test.js.map +1 -0
  21. package/dist/openapi.d.ts +14 -27
  22. package/dist/openapi.d.ts.map +1 -1
  23. package/dist/openapi.js +101 -49
  24. package/dist/openapi.js.map +1 -1
  25. package/dist/openapi.test.js +242 -18
  26. package/dist/openapi.test.js.map +1 -1
  27. package/dist/spiceflow.d.ts +5 -3
  28. package/dist/spiceflow.d.ts.map +1 -1
  29. package/dist/spiceflow.js +42 -10
  30. package/dist/spiceflow.js.map +1 -1
  31. package/dist/spiceflow.test.js +21 -3
  32. package/dist/spiceflow.test.js.map +1 -1
  33. package/dist/stream.test.js +4 -2
  34. package/dist/stream.test.js.map +1 -1
  35. package/dist/types.d.ts +7 -13
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +20 -5
  39. package/src/cors.ts +14 -2
  40. package/src/index.ts +2 -1
  41. package/src/mcp-transport.ts +148 -0
  42. package/src/mcp.test.ts +273 -0
  43. package/src/mcp.ts +270 -0
  44. package/src/openapi.test.ts +238 -18
  45. package/src/openapi.ts +136 -66
  46. package/src/spiceflow.test.ts +27 -3
  47. package/src/spiceflow.ts +83 -13
  48. package/src/stream.test.ts +4 -2
  49. package/src/types.ts +129 -140
@@ -0,0 +1,148 @@
1
+ // https://github.com/modelcontextprotocol/typescript-sdk/blob/3164da64d085ec4e022ae881329eee7b72f208d4/src/server/sse.ts
2
+ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
3
+ import {
4
+ JSONRPCMessage,
5
+ JSONRPCMessageSchema,
6
+ } from '@modelcontextprotocol/sdk/types.js'
7
+ import { randomUUID } from 'node:crypto'
8
+
9
+ /**
10
+ * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
11
+ *
12
+ * This transport is only available in Node.js environments.
13
+ */
14
+ export class SSEServerTransportSpiceflow implements Transport {
15
+ private _sessionId: string
16
+ private _endpoint: string
17
+ private _writableStream?: WritableStreamDefaultWriter<Uint8Array>
18
+ response?: Response
19
+
20
+ onclose?: () => void
21
+ onerror?: (error: Error) => void
22
+ onmessage?: (message: JSONRPCMessage) => void
23
+
24
+ /**
25
+ * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
26
+ */
27
+ constructor(endpoint: string) {
28
+ this._sessionId = randomUUID()
29
+ this._endpoint = endpoint
30
+ }
31
+
32
+ /**
33
+ * Handles the initial SSE connection request.
34
+ *
35
+ * This should be called when a GET request is made to establish the SSE stream.
36
+ */
37
+ async start(): Promise<void> {
38
+ if (this.response) {
39
+ throw new Error(
40
+ 'SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.',
41
+ )
42
+ }
43
+
44
+ const headers = new Headers({
45
+ 'Content-Type': 'text/event-stream',
46
+ 'Cache-Control': 'no-cache',
47
+ // https://github.com/vercel/next.js/issues/9965
48
+ 'content-encoding': 'none',
49
+ Connection: 'keep-alive',
50
+ })
51
+
52
+ // Create a TransformStream
53
+ const transformStream = new TransformStream()
54
+ const { readable, writable } = transformStream
55
+
56
+ // Create the Response from the readable side
57
+ this.response = new Response(readable, { headers })
58
+
59
+ // Obtain a writer from the writable end
60
+ this._writableStream = writable.getWriter()
61
+ this._writableStream?.write(
62
+ new TextEncoder().encode(
63
+ `event: endpoint\ndata: ${encodeURI(this._endpoint)}?sessionId=${
64
+ this._sessionId
65
+ }\n\n`,
66
+ ),
67
+ )
68
+
69
+ // readable.getReader().closed.then(() => {
70
+ // this.response = undefined
71
+ // this._writableStream = undefined
72
+ // this.onclose?.()
73
+ // })
74
+ }
75
+
76
+ /**
77
+ * Handles incoming POST messages.
78
+ *
79
+ * This should be called when a POST request is made to send a message to the server.
80
+ */
81
+ async handlePostMessage(
82
+ req: Request,
83
+ parsedBody?: unknown,
84
+ ): Promise<Response> {
85
+ if (!this.response) {
86
+ const message = 'SSE connection not established'
87
+ throw new Error(message)
88
+ }
89
+
90
+ let body = await req.json()
91
+
92
+ try {
93
+ await this.handleMessage(
94
+ typeof body === 'string' ? JSON.parse(body) : body,
95
+ )
96
+ } catch {
97
+ return new Response(`Invalid message: ${body}`, { status: 400 })
98
+ }
99
+
100
+ return new Response('Accepted', { status: 202 })
101
+ }
102
+
103
+ /**
104
+ * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST.
105
+ */
106
+ async handleMessage(message: unknown): Promise<void> {
107
+ let parsedMessage: JSONRPCMessage
108
+ try {
109
+ parsedMessage = JSONRPCMessageSchema.parse(message)
110
+ } catch (error) {
111
+ this.onerror?.(error as Error)
112
+ throw error
113
+ }
114
+
115
+ this.onmessage?.(parsedMessage)
116
+ }
117
+
118
+ async close(): Promise<void> {
119
+ if (this._writableStream) {
120
+ await this._writableStream.close()
121
+ }
122
+ this.response = undefined
123
+ this._writableStream = undefined
124
+ this.onclose?.()
125
+ }
126
+
127
+ async send(message: JSONRPCMessage): Promise<void> {
128
+ if (!this._writableStream) {
129
+ throw new Error('Not connected')
130
+ }
131
+
132
+ const encoder = new TextEncoder()
133
+ const data = encoder.encode(
134
+ `event: message\ndata: ${JSON.stringify(message)}\n\n`,
135
+ )
136
+
137
+ await this._writableStream.write(data)
138
+ }
139
+
140
+ /**
141
+ * Returns the session ID for this transport.
142
+ *
143
+ * This can be used to route incoming POST requests.
144
+ */
145
+ get sessionId(): string {
146
+ return this._sessionId
147
+ }
148
+ }
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, beforeEach, beforeAll } from 'vitest'
2
+ import { EventSource } from 'eventsource'
3
+
4
+ import { mcp } from './mcp.js'
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
6
+
7
+ import {
8
+ ListResourcesResultSchema,
9
+ ListToolsRequestSchema,
10
+ ListToolsResultSchema,
11
+ ReadResourceResultSchema,
12
+ CallToolResultSchema,
13
+ } from '@modelcontextprotocol/sdk/types.js'
14
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
15
+ import { z } from 'zod'
16
+ import { Spiceflow } from './spiceflow.js'
17
+ describe('MCP Plugin', () => {
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ ;(global as any).EventSource = EventSource
20
+
21
+ let app: Spiceflow
22
+ let port: number
23
+ let client: Client
24
+ let transport: SSEClientTransport
25
+
26
+ beforeAll(async () => {
27
+ port = await getAvailablePort()
28
+
29
+ app = new Spiceflow()
30
+ .use(mcp({ path: '/mcp' }))
31
+ .get('/goSomething', () => 'hi')
32
+ .get('/users', () => ({ users: [{ id: 1, name: 'John' }] }))
33
+ .get(
34
+ '/somethingElse/:id',
35
+ ({ params: { id } }) => {
36
+ return 'hello ' + id
37
+ },
38
+ {
39
+ params: z.object({ id: z.string() }),
40
+ },
41
+ )
42
+ .get(
43
+ '/search',
44
+ ({ query }) => {
45
+ return { results: [`Found results for: ${query.q}`] }
46
+ },
47
+ {
48
+ query: z
49
+ .object({
50
+ q: z.string().describe('Search query'),
51
+ limit: z.number().optional().describe('Max number of results'),
52
+ })
53
+ .required(),
54
+ },
55
+ )
56
+ await app.listen(port)
57
+
58
+ transport = new SSEClientTransport(new URL(`http://localhost:${port}/mcp`))
59
+
60
+ client = new Client(
61
+ {
62
+ name: 'example-client',
63
+ version: '1.0.0',
64
+ },
65
+ {
66
+ capabilities: {},
67
+ },
68
+ )
69
+
70
+ await client.connect(transport)
71
+ })
72
+
73
+ it('should list and call available tools', async () => {
74
+ const resources = await client.request(
75
+ { method: 'tools/list' },
76
+ ListToolsResultSchema,
77
+ )
78
+
79
+ expect(resources).toBeDefined()
80
+ expect(resources).toHaveProperty('tools')
81
+ expect(resources).toMatchInlineSnapshot(`
82
+ {
83
+ "tools": [
84
+ {
85
+ "description": "GET /goSomething",
86
+ "inputSchema": {
87
+ "properties": {},
88
+ "required": [],
89
+ "type": "object",
90
+ },
91
+ "name": "GET /goSomething",
92
+ },
93
+ {
94
+ "description": "GET /users",
95
+ "inputSchema": {
96
+ "properties": {},
97
+ "required": [],
98
+ "type": "object",
99
+ },
100
+ "name": "GET /users",
101
+ },
102
+ {
103
+ "description": "GET /somethingElse/:id",
104
+ "inputSchema": {
105
+ "properties": {
106
+ "params": {
107
+ "$schema": "http://json-schema.org/draft-07/schema#",
108
+ "additionalProperties": false,
109
+ "properties": {
110
+ "id": {
111
+ "type": "string",
112
+ },
113
+ },
114
+ "required": [
115
+ "id",
116
+ ],
117
+ "type": "object",
118
+ },
119
+ },
120
+ "required": [],
121
+ "type": "object",
122
+ },
123
+ "name": "GET /somethingElse/:id",
124
+ },
125
+ {
126
+ "description": "GET /search",
127
+ "inputSchema": {
128
+ "properties": {
129
+ "query": {
130
+ "$schema": "http://json-schema.org/draft-07/schema#",
131
+ "additionalProperties": false,
132
+ "properties": {
133
+ "limit": {
134
+ "type": "number",
135
+ },
136
+ "q": {
137
+ "description": "Search query",
138
+ "type": "string",
139
+ },
140
+ },
141
+ "required": [
142
+ "q",
143
+ "limit",
144
+ ],
145
+ "type": "object",
146
+ },
147
+ },
148
+ "required": [],
149
+ "type": "object",
150
+ },
151
+ "name": "GET /search",
152
+ },
153
+ ],
154
+ }
155
+ `)
156
+
157
+ const resourceContent = await client.request(
158
+ {
159
+ method: 'tools/call',
160
+ params: {
161
+ name: 'POST /somethingElse/:id',
162
+ arguments: {
163
+ params: { id: 'xxx' },
164
+ },
165
+ },
166
+ },
167
+ CallToolResultSchema,
168
+ )
169
+
170
+ expect(resourceContent).toBeDefined()
171
+ expect(resourceContent).toHaveProperty('content')
172
+ expect(resourceContent).toMatchInlineSnapshot(`
173
+ {
174
+ "content": [
175
+ {
176
+ "text": "Tool POST /somethingElse/:id not found",
177
+ "type": "text",
178
+ },
179
+ ],
180
+ "isError": true,
181
+ }
182
+ `)
183
+ })
184
+
185
+ it('should list and read available resources', async () => {
186
+ const resources = await client.request(
187
+ { method: 'resources/list' },
188
+ ListResourcesResultSchema,
189
+ )
190
+
191
+ expect(resources).toBeDefined()
192
+
193
+ expect(resources.resources).toMatchInlineSnapshot(`
194
+ [
195
+ {
196
+ "mimeType": "application/json",
197
+ "name": "GET /goSomething",
198
+ "uri": "http://localhost:3000/goSomething",
199
+ },
200
+ {
201
+ "mimeType": "application/json",
202
+ "name": "GET /users",
203
+ "uri": "http://localhost:3000/users",
204
+ },
205
+ ]
206
+ `)
207
+
208
+ const resourceContent = await client.request(
209
+ {
210
+ method: 'resources/read',
211
+ params: {
212
+ uri: `http://localhost:${port}/users`,
213
+ },
214
+ },
215
+ ReadResourceResultSchema,
216
+ )
217
+
218
+ expect(resourceContent).toBeDefined()
219
+ expect(resourceContent.contents).toMatchInlineSnapshot(`
220
+ [
221
+ {
222
+ "mimeType": "application/json",
223
+ "text": "{
224
+ "users": [
225
+ {
226
+ "id": 1,
227
+ "name": "John"
228
+ }
229
+ ]
230
+ }",
231
+ "uri": "http://localhost:3000/users",
232
+ },
233
+ ]
234
+ `)
235
+ })
236
+ })
237
+
238
+ async function getAvailablePort(startPort = 3000, maxRetries = 10) {
239
+ const net = await import('net')
240
+
241
+ return await new Promise<number>((resolve, reject) => {
242
+ let port = startPort
243
+ let attempts = 0
244
+
245
+ const checkPort = () => {
246
+ const server = net.createServer()
247
+
248
+ server.once('error', (err: any) => {
249
+ if (err.code === 'EADDRINUSE') {
250
+ attempts++
251
+ if (attempts >= maxRetries) {
252
+ reject(new Error('No available ports found'))
253
+ } else {
254
+ port++
255
+ checkPort()
256
+ }
257
+ } else {
258
+ reject(err)
259
+ }
260
+ })
261
+
262
+ server.once('listening', () => {
263
+ server.close(() => {
264
+ resolve(port)
265
+ })
266
+ })
267
+
268
+ server.listen(port)
269
+ }
270
+
271
+ checkPort()
272
+ })
273
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2
+
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListResourcesRequestSchema,
6
+ ListToolsRequestSchema,
7
+ ReadResourceRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js'
9
+ import { zodToJsonSchema } from 'zod-to-json-schema'
10
+ import { SSEServerTransportSpiceflow } from './mcp-transport.js'
11
+ import { isZodSchema, Spiceflow } from './spiceflow.js'
12
+
13
+ function getJsonSchema(schema: any) {
14
+ if (!schema) return undefined
15
+ if (isZodSchema(schema)) {
16
+ return zodToJsonSchema(schema, {})
17
+ }
18
+ return schema
19
+ }
20
+
21
+ export const mcp = <Path extends string = '/mcp'>({
22
+ path = '/mcp' as Path,
23
+ name = 'spiceflow',
24
+ version = '1.0.0',
25
+ } = {}) => {
26
+ const server = new Server(
27
+ { name, version },
28
+ {
29
+ capabilities: {
30
+ tools: {},
31
+ resources: {},
32
+ },
33
+ },
34
+ )
35
+
36
+ const transports = new Map<string, SSEServerTransportSpiceflow>()
37
+ const messagePath = path + '/message'
38
+ let app = new Spiceflow({ name: 'mcp' })
39
+ .post(messagePath, async ({ request, query }) => {
40
+ const sessionId = query.sessionId!
41
+
42
+ const t = transports.get(sessionId)
43
+ if (!t) {
44
+ return new Response('Session not found', { status: 404 })
45
+ }
46
+
47
+ await t.handlePostMessage(request)
48
+ return 'ok'
49
+ })
50
+ .get(path, async ({ request }) => {
51
+ const transport = new SSEServerTransportSpiceflow(messagePath)
52
+ transports.set(transport.sessionId, transport)
53
+ server.onclose = () => {
54
+ transports.delete(transport.sessionId)
55
+ }
56
+ await server.connect(transport)
57
+
58
+ request.signal.addEventListener('abort', () => {
59
+ transport.close().catch((error) => {
60
+ console.error('Error closing transport:', error)
61
+ })
62
+ })
63
+
64
+ if (request.method === 'POST') {
65
+ return await transport.handlePostMessage(request)
66
+ }
67
+ let routes = app
68
+ .getAllRoutes()
69
+ .filter((x) => x.path !== path && x.path !== messagePath)
70
+
71
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
72
+ return {
73
+ tools: routes.map((route) => {
74
+ const bodySchema = getJsonSchema(route.hooks?.body)
75
+ const querySchema = getJsonSchema(route.hooks?.query)
76
+ const paramsSchema = getJsonSchema(route.hooks?.params)
77
+
78
+ const properties: Record<string, any> = {}
79
+ const required: string[] = []
80
+
81
+ if (bodySchema) {
82
+ properties.body = bodySchema
83
+ required.push('body')
84
+ }
85
+ if (querySchema?.properties) {
86
+ properties.query = querySchema
87
+ }
88
+ if (paramsSchema?.properties) {
89
+ properties.params = paramsSchema
90
+ }
91
+
92
+ return {
93
+ name: getRouteName({ method: route.method, path: route.path }),
94
+
95
+ description:
96
+ route.hooks?.detail?.description ||
97
+ `${route.method} ${route.path}`,
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties,
101
+ required,
102
+ },
103
+ }
104
+ }),
105
+ }
106
+ })
107
+
108
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
109
+ const toolName = request.params.name
110
+ let { path, method } = getPathFromToolName(toolName)
111
+
112
+ const route = routes.find(
113
+ (r) =>
114
+ r.method.toUpperCase() === method.toUpperCase() && r.path === path,
115
+ )
116
+
117
+ if (!route) {
118
+ return {
119
+ content: [{ type: 'text', text: `Tool ${toolName} not found` }],
120
+ isError: true,
121
+ }
122
+ }
123
+
124
+ try {
125
+ const { body, query, params } = request.params.arguments || {}
126
+
127
+ if (params) {
128
+ Object.entries(params).forEach(([key, value]) => {
129
+ path = path.replace(`:${key}`, encodeURIComponent(String(value)))
130
+ })
131
+ }
132
+ const url = new URL(`http://localhost${path}`)
133
+ if (query) {
134
+ Object.entries(query).forEach(([key, value]) => {
135
+ url.searchParams.set(key, String(value))
136
+ })
137
+ }
138
+
139
+ const response = await app.topLevelApp!.handle(
140
+ new Request(url, {
141
+ method: route.method,
142
+ headers: {
143
+ 'content-type': 'application/json',
144
+ },
145
+ body: body ? JSON.stringify(body) : undefined,
146
+ }),
147
+ )
148
+
149
+ const isError = !response.ok
150
+
151
+ const contentType = response.headers.get('content-type')
152
+ if (contentType?.includes('application/json')) {
153
+ const json = await response.json()
154
+ return {
155
+ isError,
156
+ content: [{ type: 'text', text: JSON.stringify(json, null, 2) }],
157
+ }
158
+ }
159
+
160
+ const text = await response.text()
161
+ return {
162
+ isError,
163
+ content: [{ type: 'text', text }],
164
+ }
165
+ } catch (error: any) {
166
+ return {
167
+ content: [{ type: 'text', text: error.message || 'Unknown error' }],
168
+ isError: true,
169
+ }
170
+ }
171
+ })
172
+ const resourcesRoutes = routes.filter((route) => {
173
+ if (route.method !== 'GET') return false
174
+
175
+ if (route.path.includes(':')) return false
176
+
177
+ const querySchema = route.hooks?.query
178
+
179
+ if (querySchema) {
180
+ const jsonSchema = getJsonSchema(querySchema)
181
+ if (jsonSchema?.required?.length) {
182
+ return false
183
+ }
184
+ }
185
+
186
+ return true
187
+ })
188
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
189
+ const resources = resourcesRoutes.map((route) => ({
190
+ uri: new URL(route.path, `http://${request.headers.get('host')}`)
191
+ .href,
192
+ mimeType: 'application/json',
193
+ name: `GET ${route.path}`,
194
+ }))
195
+ return { resources }
196
+ })
197
+
198
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
199
+ const resourceUrl = new URL(request.params.uri)
200
+ const path = resourceUrl.pathname
201
+
202
+ const route = resourcesRoutes.find(
203
+ (route) => route.path === path && route.method === 'GET',
204
+ )
205
+ if (!route) {
206
+ throw new Error('Resource not found')
207
+ }
208
+
209
+ const response = await app.topLevelApp!.handle(
210
+ new Request(resourceUrl, {
211
+ method: 'GET',
212
+ headers: {
213
+ 'content-type': 'application/json',
214
+ },
215
+ }),
216
+ )
217
+
218
+ const contentType = response.headers.get('content-type')
219
+ const text = await response.text()
220
+ if (contentType?.includes('application/json')) {
221
+ return {
222
+ contents: [
223
+ {
224
+ uri: request.params.uri,
225
+ mimeType: 'application/json',
226
+ text: text,
227
+ },
228
+ ],
229
+ }
230
+ }
231
+
232
+ return {
233
+ contents: [
234
+ {
235
+ uri: request.params.uri,
236
+ mimeType: 'text/plain',
237
+ text,
238
+ },
239
+ ],
240
+ }
241
+ })
242
+
243
+ return transport.response
244
+ })
245
+
246
+ return app
247
+ }
248
+
249
+ function getRouteName({
250
+ method,
251
+ path,
252
+ }: {
253
+ method: string
254
+ path: string
255
+ }): string {
256
+ return `${method.toUpperCase()} ${path}`
257
+ }
258
+
259
+ function getPathFromToolName(toolName: string): {
260
+ path: string
261
+ method: string
262
+ } {
263
+ const parts = toolName.split(' ')
264
+ if (parts.length < 2) {
265
+ throw new Error('Invalid tool name format')
266
+ }
267
+ const method = parts[0].toUpperCase()
268
+ const path = parts.slice(1).join(' ')
269
+ return { path, method }
270
+ }