Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/mcp/bin/shopify-mcp-http.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/http.js'
3 changes: 2 additions & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"license": "MIT",
"type": "module",
"bin": {
"shopify-mcp": "./bin/shopify-mcp.js"
"shopify-mcp": "./bin/shopify-mcp.js",
"shopify-mcp-http": "./bin/shopify-mcp-http.js"
},
"files": [
"/bin",
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"outputs": ["{workspaceRoot}/dist"],
"inputs": ["{projectRoot}/src/**/*", "{projectRoot}/package.json"],
"options": {
"command": "pnpm tsc -b ./tsconfig.build.json",
"command": "pnpm tsc -b ./tsconfig.build.json && cp -r src/prompts/skills dist/prompts/skills",
"cwd": "packages/mcp"
}
},
Expand Down
120 changes: 120 additions & 0 deletions packages/mcp/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {createServer} from './server.js'
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import {createServer as createHttpServer, type IncomingMessage, type ServerResponse} from 'node:http'
import {randomUUID} from 'node:crypto'

const server = createServer()
const transports = new Map<string, StreamableHTTPServerTransport>()

const PORT = parseInt(process.env.PORT || '3000', 10)

function isInitializeRequest(body: unknown): boolean {
if (Array.isArray(body)) {
return body.some(
(msg) => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize',
)
}
return typeof body === 'object' && body !== null && 'method' in body && (body as {method: string}).method === 'initialize'
}

function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks).toString()))
req.on('error', reject)
})
}

async function handlePost(req: IncomingMessage, res: ServerResponse) {
const rawBody = await readBody(req)
const body = JSON.parse(rawBody) as unknown
const sessionId = req.headers['mcp-session-id'] as string | undefined

if (sessionId && transports.has(sessionId)) {
await transports.get(sessionId)!.handleRequest(req, res, body)
return
}

if (!sessionId && isInitializeRequest(body)) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports.set(id, transport)
},
})

transport.onclose = () => {
if (transport.sessionId) {
transports.delete(transport.sessionId)
}
}

await server.connect(transport)
await transport.handleRequest(req, res, body)
return
}

res.writeHead(400, {'Content-Type': 'application/json'})
res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32600, message: 'Bad request: missing or invalid session'}, id: null}))
}

async function handleGet(req: IncomingMessage, res: ServerResponse) {
const sessionId = req.headers['mcp-session-id'] as string | undefined
if (!sessionId || !transports.has(sessionId)) {
res.writeHead(400, {'Content-Type': 'application/json'})
res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32600, message: 'Invalid or missing session'}, id: null}))
return
}
await transports.get(sessionId)!.handleRequest(req, res)
}

async function handleDelete(req: IncomingMessage, res: ServerResponse) {
const sessionId = req.headers['mcp-session-id'] as string | undefined
if (!sessionId || !transports.has(sessionId)) {
res.writeHead(400, {'Content-Type': 'application/json'})
res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32600, message: 'Invalid or missing session'}, id: null}))
return
}
await transports.get(sessionId)!.handleRequest(req, res)
}

const httpServer = createHttpServer(async (req, res) => {
if (req.url !== '/mcp') {
res.writeHead(404, {'Content-Type': 'application/json'})
res.end(JSON.stringify({error: 'Not found'}))
return
}

try {
if (req.method === 'POST') {
await handlePost(req, res)
} else if (req.method === 'GET') {
await handleGet(req, res)
} else if (req.method === 'DELETE') {
await handleDelete(req, res)
} else {
res.writeHead(405, {'Content-Type': 'application/json'})
res.end(JSON.stringify({error: 'Method not allowed'}))
}
} catch (error) {
if (!res.headersSent) {
res.writeHead(500, {'Content-Type': 'application/json'})
res.end(JSON.stringify({jsonrpc: '2.0', error: {code: -32603, message: 'Internal server error'}, id: null}))
}
}
})

httpServer.listen(PORT, () => {
console.error(`Shopify MCP server (HTTP) listening on http://localhost:${PORT}/mcp`)
})

const shutdown = () => {
httpServer.close()
const _closing = server
.close()
.then(() => process.exit(0))
.catch(() => process.exit(1))
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
32 changes: 32 additions & 0 deletions packages/mcp/src/prompts/liquid_themes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {handleLiquidThemesSkill} from './liquid_themes.js'
import {describe, test, expect} from 'vitest'

describe('handleLiquidThemesSkill', () => {
test('returns a single text content item', () => {
const result = handleLiquidThemesSkill()

expect(result.content).toHaveLength(1)
expect(result.content[0]!.type).toBe('text')
expect(result.content[0]!.text.length).toBeGreaterThan(0)
})

test('includes SKILL.md and reference files separated by ---', () => {
const result = handleLiquidThemesSkill()
const sections = result.content[0]!.text.split('\n\n---\n\n')

expect(sections.length).toBe(11)
for (const section of sections.slice(1)) {
expect(section).toMatch(/^# Reference: /)
}
})

test('reference files are sorted alphabetically', () => {
const result = handleLiquidThemesSkill()
const refNames = result.content[0]!.text
.split('\n\n---\n\n')
.slice(1)
.map((s) => s.split('\n')[0])

expect(refNames).toEqual([...refNames].sort())
})
})
35 changes: 35 additions & 0 deletions packages/mcp/src/prompts/liquid_themes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {readFileSync, readdirSync} from 'fs'
import {join, dirname} from 'path'
import {fileURLToPath} from 'url'

import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'

const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), 'skills', 'shopify-liquid-themes')

function readSkillFile(): string {
return readFileSync(join(SKILL_DIR, 'SKILL.md'), 'utf-8')
}

function readReferenceFiles(): Array<{name: string; content: string}> {
const refsDir = join(SKILL_DIR, 'references')
return readdirSync(refsDir)
.filter((f) => f.endsWith('.md'))
.sort()
.map((name) => ({name, content: readFileSync(join(refsDir, name), 'utf-8')}))
}

export function handleLiquidThemesSkill(): {content: Array<{type: 'text'; text: string}>} {
console.error('[tool_call] shopify_liquid_themes')
const skill = readSkillFile()
const refs = readReferenceFiles()
const text = [skill, ...refs.map((r) => `# Reference: ${r.name}\n\n${r.content}`)].join('\n\n---\n\n')
return {content: [{type: 'text', text}]}
}

export function registerLiquidThemesTool(server: McpServer) {
server.tool(
'shopify_liquid_themes',
'Liquid syntax, filters, tags, objects, schema, and settings for Shopify themes. Call this tool to get comprehensive Liquid theme development guidance.',
() => handleLiquidThemesSkill(),
)
}
Loading
Loading