Skip to content

Commit dbf3203

Browse files
authored
support MCP (#25)
* support MCP * update README
1 parent ff57175 commit dbf3203

9 files changed

Lines changed: 862 additions & 6 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ You might want to say _why Ramen?_ And, I will say. **_:ramen: is super deliciou
1818

1919
## Features
2020

21-
- :star2: Support REST API and GraphQL.
21+
- :star2: Support REST API, GraphQL, and remote MCP.
2222
- :framed_picture: We can get an information of Ramen shops and their rich photos.
2323
- :free: Completely free.
2424
- :technologist: You can contribute by adding Ramen content.
@@ -315,6 +315,16 @@ query {
315315
}
316316
```
317317

318+
## Remote MCP
319+
320+
Ramen API supports a remote MCP.
321+
322+
### Streamable HTTP endpoint
323+
324+
```sh
325+
https://ramen-api.dev/mcp
326+
```
327+
318328
## Contribution
319329

320330
You can contribute by adding Ramen content to this project. Not only by writing code.

jest.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
export default {
22
testMatch: ['**/test/**/*.+(ts|tsx)', '**/validation/**/*.+(ts|tsx)'],
33
transform: {
4-
'^.+\\.(ts|tsx)$': 'esbuild-jest',
4+
'^.+\\.(ts|tsx|js)$': 'esbuild-jest',
55
},
6+
transformIgnorePatterns: [
7+
'/node_modules/(?!fetch-to-node|@modelcontextprotocol).+\\.js$',
8+
],
69
moduleNameMapper: {
710
'^@/(.*)$': '<rootDir>/src/$1',
811
},

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
"license": "MIT",
1414
"dependencies": {
1515
"@hono/graphql-server": "^0.4.1",
16+
"@modelcontextprotocol/sdk": "^1.11.4",
17+
"fetch-to-node": "^2.1.0",
1618
"graphql": "^16.6.0",
17-
"hono": "^4.7.10"
19+
"hono": "^4.7.10",
20+
"zod": "^3.25.7"
1821
},
1922
"devDependencies": {
2023
"@cloudflare/workers-types": "^4.20231218.0",

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cors } from 'hono/cors'
66
import { poweredBy } from 'hono/powered-by'
77
import { prettyJSON } from 'hono/pretty-json'
88
import { getMimeType } from 'hono/utils/mime'
9+
import mcpApp from './mcp'
910
import { getContentFromKVAsset } from './workers-utils'
1011
import { getShop, getAuthor, listShopsWithPager } from '@/app'
1112
import { createErrorMessage } from '@/error'
@@ -116,4 +117,6 @@ app.use('/graphql', (c) => {
116117
})(c)
117118
})
118119

120+
app.route('/mcp', mcpApp)
121+
119122
export default app

src/mcp.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
3+
import { toFetchResponse, toReqRes } from 'fetch-to-node'
4+
import type { Context } from 'hono'
5+
import { Hono } from 'hono'
6+
import { z } from 'zod'
7+
import type { Env } from './app'
8+
import { getShop, listShopsWithPager } from './app'
9+
10+
export const getMcpServer = async (c: Context<Env>) => {
11+
const server = new McpServer({
12+
name: 'Ramen API MCP Server',
13+
version: '0.0.1',
14+
})
15+
server.tool(
16+
'get_shops',
17+
'Get ramen shops',
18+
{
19+
perPage: z.number().min(1),
20+
page: z.number().min(1),
21+
},
22+
async ({ perPage, page }) => {
23+
const result = await listShopsWithPager({ perPage, page }, { c })
24+
return {
25+
content: [
26+
{
27+
type: 'text',
28+
text: JSON.stringify(result),
29+
},
30+
],
31+
}
32+
}
33+
)
34+
server.tool(
35+
'get_shop',
36+
'Get a shop information',
37+
{
38+
shopId: z.string(),
39+
},
40+
async ({ shopId }) => {
41+
const shop = await getShop(shopId, { c })
42+
return {
43+
content: [
44+
{
45+
type: 'text',
46+
text: JSON.stringify(shop),
47+
},
48+
],
49+
}
50+
}
51+
)
52+
return server
53+
}
54+
55+
const app = new Hono<Env>()
56+
57+
app.post('/', async (c) => {
58+
const { req, res } = toReqRes(c.req.raw)
59+
const mcpServer = await getMcpServer(c)
60+
const transport: StreamableHTTPServerTransport =
61+
new StreamableHTTPServerTransport({
62+
sessionIdGenerator: undefined,
63+
})
64+
await mcpServer.connect(transport)
65+
await transport.handleRequest(req, res, await c.req.json())
66+
res.on('close', () => {
67+
transport.close()
68+
mcpServer.close()
69+
})
70+
return toFetchResponse(res)
71+
})
72+
73+
app.on(['GET', 'DELETE'], '/', (c) => {
74+
return c.json(
75+
{
76+
jsonrpc: '2.0',
77+
error: {
78+
code: -32000,
79+
message: 'Method not allowed.',
80+
},
81+
id: null,
82+
},
83+
405
84+
)
85+
})
86+
87+
app.onError((e, c) => {
88+
console.error(e.message)
89+
return c.json(
90+
{
91+
jsonrpc: '2.0',
92+
error: {
93+
code: -32603,
94+
message: 'Internal server error',
95+
},
96+
id: null,
97+
},
98+
500
99+
)
100+
})
101+
102+
export default app

test/mcp.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { app } from '@/index'
2+
3+
describe('Test /mcp', () => {
4+
it('Should return initialize response', async () => {
5+
const res = await app.request('/mcp', {
6+
method: 'POST',
7+
body: JSON.stringify({
8+
jsonrpc: '2.0',
9+
id: 1,
10+
method: 'initialize',
11+
params: {
12+
protocolVersion: '2024-11-05',
13+
clientInfo: {
14+
name: 'test-client',
15+
version: '0.0.0',
16+
},
17+
capabilities: { tools: true },
18+
},
19+
}),
20+
headers: {
21+
'Content-Type': 'application/json',
22+
Accept: 'application/json, text/event-stream',
23+
},
24+
})
25+
26+
const messages = await parseSSEJSONResponse(res)
27+
const result = messages.find((m) => m.id === 1)
28+
29+
expect(result.result.serverInfo.name).toBe('Ramen API MCP Server')
30+
expect(res.status).toBe(200)
31+
})
32+
33+
it('Should return a list of tools with correct properties', async () => {
34+
const res = await app.request('/mcp', {
35+
method: 'POST',
36+
body: JSON.stringify({
37+
jsonrpc: '2.0',
38+
id: 2,
39+
method: 'tools/list',
40+
params: {},
41+
}),
42+
headers: {
43+
'Content-Type': 'application/json',
44+
Accept: 'application/json, text/event-stream',
45+
},
46+
})
47+
48+
const messages = await parseSSEJSONResponse(res)
49+
const result = messages.find((m) => m.id === 2)
50+
51+
expect(res.status).toBe(200)
52+
expect(result).toHaveProperty('result')
53+
expect(Array.isArray(result.result.tools)).toBe(true)
54+
expect(result.result.tools.length).toBeGreaterThan(0)
55+
56+
const getShopsTool = result.result.tools.find(
57+
(tool) => tool.name === 'get_shops'
58+
)
59+
expect(getShopsTool).toBeDefined()
60+
expect(getShopsTool).toHaveProperty('description', 'Get ramen shops')
61+
expect(getShopsTool).toHaveProperty('inputSchema')
62+
expect(getShopsTool.inputSchema).toHaveProperty('type', 'object')
63+
expect(getShopsTool.inputSchema.properties).toHaveProperty('perPage')
64+
expect(getShopsTool.inputSchema.properties.perPage).toHaveProperty(
65+
'type',
66+
'number'
67+
)
68+
expect(getShopsTool.inputSchema.properties.perPage).toHaveProperty(
69+
'minimum',
70+
1
71+
)
72+
expect(getShopsTool.inputSchema.properties).toHaveProperty('page')
73+
expect(getShopsTool.inputSchema.properties.page).toHaveProperty(
74+
'type',
75+
'number'
76+
)
77+
expect(getShopsTool.inputSchema.properties.page).toHaveProperty(
78+
'minimum',
79+
1
80+
)
81+
82+
const getShopTool = result.result.tools.find(
83+
(tool) => tool.name === 'get_shop'
84+
)
85+
expect(getShopTool).toBeDefined()
86+
expect(getShopTool).toHaveProperty('description', 'Get a shop information')
87+
expect(getShopTool).toHaveProperty('inputSchema')
88+
expect(getShopTool.inputSchema).toHaveProperty('type', 'object')
89+
expect(getShopTool.inputSchema.properties).toHaveProperty('shopId')
90+
expect(getShopTool.inputSchema.properties.shopId).toHaveProperty(
91+
'type',
92+
'string'
93+
)
94+
})
95+
96+
it('Should execute get_shops tool and return a list of shops', async () => {
97+
const res = await app.request('/mcp', {
98+
method: 'POST',
99+
body: JSON.stringify({
100+
jsonrpc: '2.0',
101+
id: 3,
102+
method: 'tools/call',
103+
params: {
104+
name: 'get_shops',
105+
arguments: {
106+
perPage: 5,
107+
page: 1,
108+
},
109+
},
110+
}),
111+
headers: {
112+
'Content-Type': 'application/json',
113+
Accept: 'application/json, text/event-stream',
114+
},
115+
})
116+
117+
const messages = await parseSSEJSONResponse(res)
118+
const result = messages.find((m) => m.id === 3)
119+
120+
expect(res.status).toBe(200)
121+
expect(result).toHaveProperty('result')
122+
expect(result.result).toHaveProperty('content')
123+
expect(Array.isArray(result.result.content)).toBe(true)
124+
125+
const content = result.result.content[0]
126+
expect(content).toHaveProperty('type', 'text')
127+
expect(content).toHaveProperty('text')
128+
129+
const shops = JSON.parse(content.text)
130+
expect(shops).toHaveProperty('shops')
131+
expect(Array.isArray(shops.shops)).toBe(true)
132+
expect(shops.shops.length).toBeGreaterThan(0)
133+
})
134+
135+
it('Should execute get_shop tool and return shop details', async () => {
136+
const res = await app.request('/mcp', {
137+
method: 'POST',
138+
body: JSON.stringify({
139+
jsonrpc: '2.0',
140+
id: 4,
141+
method: 'tools/call',
142+
params: {
143+
name: 'get_shop',
144+
arguments: {
145+
shopId: 'yoshimuraya',
146+
},
147+
},
148+
}),
149+
headers: {
150+
'Content-Type': 'application/json',
151+
Accept: 'application/json, text/event-stream',
152+
},
153+
})
154+
155+
const messages = await parseSSEJSONResponse(res)
156+
const result = messages.find((m) => m.id === 4)
157+
158+
expect(res.status).toBe(200)
159+
160+
expect(result).toHaveProperty('result')
161+
expect(result.result).toHaveProperty('content')
162+
expect(Array.isArray(result.result.content)).toBe(true)
163+
164+
const content = result.result.content[0]
165+
expect(content).toHaveProperty('type', 'text')
166+
expect(content).toHaveProperty('text')
167+
168+
const shop = JSON.parse(content.text)
169+
expect(shop).toHaveProperty('id', 'yoshimuraya')
170+
expect(shop).toHaveProperty('name') // 店名が存在することを確認
171+
expect(shop).toHaveProperty('photos') // 写真データが存在することを確認
172+
expect(Array.isArray(shop.photos)).toBe(true)
173+
})
174+
})
175+
176+
export async function parseSSEJSONResponse(res: Response) {
177+
const text = await res.text()
178+
const lines = text.split('\n')
179+
const dataLines = lines.filter((line) => line.startsWith('data: '))
180+
const jsonStrings = dataLines.map((line) => line.slice(6))
181+
return jsonStrings.map((json) => JSON.parse(json))
182+
}

tsconfig.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{
22
"compilerOptions": {
3+
"module": "ESNext",
4+
"moduleResolution": "bundler",
35
"baseUrl": ".",
46
"paths": {
57
"@/*": [

wrangler.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ main = "src/index.ts"
55

66
routes = ["ramen-api.dev/*"]
77

8-
compatibility_date = "2023-03-01"
8+
compatibility_date = "2024-09-23"
9+
compatibility_flags = [ "nodejs_compat" ]
910

1011
[site]
1112
bucket = "./content"

0 commit comments

Comments
 (0)