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.
- package/README.md +245 -10
- package/dist/cors.d.ts +2 -0
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +10 -2
- package/dist/cors.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-transport.d.ts +45 -0
- package/dist/mcp-transport.d.ts.map +1 -0
- package/dist/mcp-transport.js +107 -0
- package/dist/mcp-transport.js.map +1 -0
- package/dist/mcp.d.ts +36 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +211 -0
- package/dist/mcp.js.map +1 -0
- package/dist/mcp.test.d.ts +2 -0
- package/dist/mcp.test.d.ts.map +1 -0
- package/dist/mcp.test.js +224 -0
- package/dist/mcp.test.js.map +1 -0
- package/dist/openapi.d.ts +14 -27
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +101 -49
- package/dist/openapi.js.map +1 -1
- package/dist/openapi.test.js +242 -18
- package/dist/openapi.test.js.map +1 -1
- package/dist/spiceflow.d.ts +5 -3
- package/dist/spiceflow.d.ts.map +1 -1
- package/dist/spiceflow.js +42 -10
- package/dist/spiceflow.js.map +1 -1
- package/dist/spiceflow.test.js +21 -3
- package/dist/spiceflow.test.js.map +1 -1
- package/dist/stream.test.js +4 -2
- package/dist/stream.test.js.map +1 -1
- package/dist/types.d.ts +7 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +20 -5
- package/src/cors.ts +14 -2
- package/src/index.ts +2 -1
- package/src/mcp-transport.ts +148 -0
- package/src/mcp.test.ts +273 -0
- package/src/mcp.ts +270 -0
- package/src/openapi.test.ts +238 -18
- package/src/openapi.ts +136 -66
- package/src/spiceflow.test.ts +27 -3
- package/src/spiceflow.ts +83 -13
- package/src/stream.test.ts +4 -2
- 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
|
+
}
|
package/src/mcp.test.ts
ADDED
|
@@ -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
|
+
}
|