这是indexloc提供的服务,不要输入任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ You might want to say _why Ramen?_ And, I will say. **_:ramen: is super deliciou

## Features

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

## Remote MCP

Ramen API supports a remote MCP.

### Streamable HTTP endpoint

```sh
https://ramen-api.dev/mcp
```

## Contribution

You can contribute by adding Ramen content to this project. Not only by writing code.
Expand Down
5 changes: 4 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
export default {
testMatch: ['**/test/**/*.+(ts|tsx)', '**/validation/**/*.+(ts|tsx)'],
transform: {
'^.+\\.(ts|tsx)$': 'esbuild-jest',
'^.+\\.(ts|tsx|js)$': 'esbuild-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!fetch-to-node|@modelcontextprotocol).+\\.js$',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
"license": "MIT",
"dependencies": {
"@hono/graphql-server": "^0.4.1",
"@modelcontextprotocol/sdk": "^1.11.4",
"fetch-to-node": "^2.1.0",
"graphql": "^16.6.0",
"hono": "^4.7.10"
"hono": "^4.7.10",
"zod": "^3.25.7"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20231218.0",
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cors } from 'hono/cors'
import { poweredBy } from 'hono/powered-by'
import { prettyJSON } from 'hono/pretty-json'
import { getMimeType } from 'hono/utils/mime'
import mcpApp from './mcp'
import { getContentFromKVAsset } from './workers-utils'
import { getShop, getAuthor, listShopsWithPager } from '@/app'
import { createErrorMessage } from '@/error'
Expand Down Expand Up @@ -116,4 +117,6 @@ app.use('/graphql', (c) => {
})(c)
})

app.route('/mcp', mcpApp)

export default app
102 changes: 102 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { toFetchResponse, toReqRes } from 'fetch-to-node'
import type { Context } from 'hono'
import { Hono } from 'hono'
import { z } from 'zod'
import type { Env } from './app'
import { getShop, listShopsWithPager } from './app'

export const getMcpServer = async (c: Context<Env>) => {
const server = new McpServer({
name: 'Ramen API MCP Server',
version: '0.0.1',
})
server.tool(
'get_shops',
'Get ramen shops',
{
perPage: z.number().min(1),
page: z.number().min(1),
},
async ({ perPage, page }) => {
const result = await listShopsWithPager({ perPage, page }, { c })
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
}
}
)
server.tool(
'get_shop',
'Get a shop information',
{
shopId: z.string(),
},
async ({ shopId }) => {
const shop = await getShop(shopId, { c })
return {
content: [
{
type: 'text',
text: JSON.stringify(shop),
},
],
}
}
)
return server
}

const app = new Hono<Env>()

app.post('/', async (c) => {
const { req, res } = toReqRes(c.req.raw)
const mcpServer = await getMcpServer(c)
const transport: StreamableHTTPServerTransport =
new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
await mcpServer.connect(transport)
await transport.handleRequest(req, res, await c.req.json())
res.on('close', () => {
transport.close()
mcpServer.close()
})
return toFetchResponse(res)
})

app.on(['GET', 'DELETE'], '/', (c) => {
return c.json(
{
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.',
},
id: null,
},
405
)
})

app.onError((e, c) => {
console.error(e.message)
return c.json(
{
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
},
500
)
})

export default app
182 changes: 182 additions & 0 deletions test/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { app } from '@/index'

describe('Test /mcp', () => {
it('Should return initialize response', async () => {
const res = await app.request('/mcp', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
clientInfo: {
name: 'test-client',
version: '0.0.0',
},
capabilities: { tools: true },
},
}),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
})

const messages = await parseSSEJSONResponse(res)
const result = messages.find((m) => m.id === 1)

expect(result.result.serverInfo.name).toBe('Ramen API MCP Server')
expect(res.status).toBe(200)
})

it('Should return a list of tools with correct properties', async () => {
const res = await app.request('/mcp', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'tools/list',
params: {},
}),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
})

const messages = await parseSSEJSONResponse(res)
const result = messages.find((m) => m.id === 2)

expect(res.status).toBe(200)
expect(result).toHaveProperty('result')
expect(Array.isArray(result.result.tools)).toBe(true)
expect(result.result.tools.length).toBeGreaterThan(0)

const getShopsTool = result.result.tools.find(
(tool) => tool.name === 'get_shops'
)
expect(getShopsTool).toBeDefined()
expect(getShopsTool).toHaveProperty('description', 'Get ramen shops')
expect(getShopsTool).toHaveProperty('inputSchema')
expect(getShopsTool.inputSchema).toHaveProperty('type', 'object')
expect(getShopsTool.inputSchema.properties).toHaveProperty('perPage')
expect(getShopsTool.inputSchema.properties.perPage).toHaveProperty(
'type',
'number'
)
expect(getShopsTool.inputSchema.properties.perPage).toHaveProperty(
'minimum',
1
)
expect(getShopsTool.inputSchema.properties).toHaveProperty('page')
expect(getShopsTool.inputSchema.properties.page).toHaveProperty(
'type',
'number'
)
expect(getShopsTool.inputSchema.properties.page).toHaveProperty(
'minimum',
1
)

const getShopTool = result.result.tools.find(
(tool) => tool.name === 'get_shop'
)
expect(getShopTool).toBeDefined()
expect(getShopTool).toHaveProperty('description', 'Get a shop information')
expect(getShopTool).toHaveProperty('inputSchema')
expect(getShopTool.inputSchema).toHaveProperty('type', 'object')
expect(getShopTool.inputSchema.properties).toHaveProperty('shopId')
expect(getShopTool.inputSchema.properties.shopId).toHaveProperty(
'type',
'string'
)
})

it('Should execute get_shops tool and return a list of shops', async () => {
const res = await app.request('/mcp', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'get_shops',
arguments: {
perPage: 5,
page: 1,
},
},
}),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
})

const messages = await parseSSEJSONResponse(res)
const result = messages.find((m) => m.id === 3)

expect(res.status).toBe(200)
expect(result).toHaveProperty('result')
expect(result.result).toHaveProperty('content')
expect(Array.isArray(result.result.content)).toBe(true)

const content = result.result.content[0]
expect(content).toHaveProperty('type', 'text')
expect(content).toHaveProperty('text')

const shops = JSON.parse(content.text)
expect(shops).toHaveProperty('shops')
expect(Array.isArray(shops.shops)).toBe(true)
expect(shops.shops.length).toBeGreaterThan(0)
})

it('Should execute get_shop tool and return shop details', async () => {
const res = await app.request('/mcp', {
method: 'POST',
body: JSON.stringify({
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: {
name: 'get_shop',
arguments: {
shopId: 'yoshimuraya',
},
},
}),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
})

const messages = await parseSSEJSONResponse(res)
const result = messages.find((m) => m.id === 4)

expect(res.status).toBe(200)

expect(result).toHaveProperty('result')
expect(result.result).toHaveProperty('content')
expect(Array.isArray(result.result.content)).toBe(true)

const content = result.result.content[0]
expect(content).toHaveProperty('type', 'text')
expect(content).toHaveProperty('text')

const shop = JSON.parse(content.text)
expect(shop).toHaveProperty('id', 'yoshimuraya')
expect(shop).toHaveProperty('name') // 店名が存在することを確認
expect(shop).toHaveProperty('photos') // 写真データが存在することを確認
expect(Array.isArray(shop.photos)).toBe(true)
})
})

export async function parseSSEJSONResponse(res: Response) {
const text = await res.text()
const lines = text.split('\n')
const dataLines = lines.filter((line) => line.startsWith('data: '))
const jsonStrings = dataLines.map((line) => line.slice(6))
return jsonStrings.map((json) => JSON.parse(json))
}
2 changes: 2 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": [
Expand Down
3 changes: 2 additions & 1 deletion wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ main = "src/index.ts"

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

compatibility_date = "2023-03-01"
compatibility_date = "2024-09-23"
compatibility_flags = [ "nodejs_compat" ]

[site]
bucket = "./content"
Expand Down
Loading