Skip to content

Commit cbb8d13

Browse files
committed
Add MCP server source, package.json, and license
1 parent 2be965b commit cbb8d13

4 files changed

Lines changed: 268 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
.vercel/
3+
.env

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Rhylthyme
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "rhylthyme-mcp",
3+
"version": "1.0.0",
4+
"description": "MCP server for creating interactive schedule visualizations on rhylthyme.com",
5+
"main": "src/index.js",
6+
"scripts": {
7+
"start": "node src/index.js"
8+
},
9+
"keywords": [
10+
"mcp",
11+
"mcp-server",
12+
"model-context-protocol",
13+
"scheduling",
14+
"workflow",
15+
"visualization",
16+
"gantt",
17+
"timeline"
18+
],
19+
"author": "Rhylthyme",
20+
"license": "MIT",
21+
"dependencies": {
22+
"@modelcontextprotocol/sdk": "^1.0.0",
23+
"mcp-handler": "^0.1.0",
24+
"zod": "^3.22.0"
25+
}
26+
}

src/index.js

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Rhylthyme MCP Server (Streamable HTTP)
2+
// Deployed at https://mcp.rhylthyme.com/mcp
3+
// Uses @vercel/node with mcp-handler
4+
5+
const { z } = require("zod");
6+
7+
const API_BASE = "https://www.rhylthyme.com";
8+
9+
let handlerPromise;
10+
11+
function getHandler() {
12+
if (!handlerPromise) {
13+
handlerPromise = (async () => {
14+
const { createMcpHandler } = await import("mcp-handler");
15+
16+
const webHandler = createMcpHandler(
17+
(server) => {
18+
server.tool(
19+
"visualize_schedule",
20+
"Create an interactive schedule visualization on rhylthyme.com. Takes a Rhylthyme program JSON with tracks, steps, durations, triggers, and resource constraints. Returns a shareable URL. Use for cooking schedules, lab protocols, event coordination, or any multi-step timed process.",
21+
{
22+
program: z.object({
23+
schemaVersion: z.string().default("0.1.0"),
24+
programId: z.string().describe("Kebab-case identifier"),
25+
name: z.string().describe("Human-readable name"),
26+
description: z.string().optional(),
27+
environmentType: z.string().optional(),
28+
actors: z.number().int().optional(),
29+
tracks: z.array(z.object({
30+
trackId: z.string(),
31+
name: z.string(),
32+
description: z.string().optional(),
33+
steps: z.array(z.object({
34+
stepId: z.string(),
35+
name: z.string(),
36+
description: z.string().optional(),
37+
task: z.string(),
38+
duration: z.object({
39+
type: z.enum(["fixed", "variable", "indefinite"]),
40+
seconds: z.number().int().optional(),
41+
minSeconds: z.number().int().optional(),
42+
maxSeconds: z.number().int().optional(),
43+
defaultSeconds: z.number().int().optional(),
44+
triggerName: z.string().optional(),
45+
}),
46+
startTrigger: z.object({
47+
type: z.enum(["programStart", "afterStep", "programStartOffset", "manual"]),
48+
stepId: z.string().optional(),
49+
offsetSeconds: z.number().int().optional(),
50+
}),
51+
})),
52+
})),
53+
resourceConstraints: z.array(z.object({
54+
task: z.string(),
55+
maxConcurrent: z.number().int(),
56+
description: z.string().optional(),
57+
})).optional(),
58+
metadata: z.object({
59+
ingredients: z.array(z.object({
60+
name: z.string(),
61+
measure: z.string(),
62+
})).optional(),
63+
serves: z.string().optional(),
64+
sourceUrl: z.string().optional(),
65+
attribution: z.string().optional(),
66+
}).optional(),
67+
}),
68+
},
69+
async ({ program }) => {
70+
try {
71+
const shareResp = await fetch(`${API_BASE}/api/share`, {
72+
method: "POST",
73+
headers: { "Content-Type": "application/json" },
74+
body: JSON.stringify({ program }),
75+
});
76+
77+
if (shareResp.ok) {
78+
const shareData = await shareResp.json();
79+
const shareUrl = shareData.url || `${API_BASE}?share=${shareData.shareId}`;
80+
const trackCount = program.tracks.length;
81+
const stepCount = program.tracks.reduce((sum, t) => sum + t.steps.length, 0);
82+
const tracksSummary = program.tracks
83+
.map((t) => ` - **${t.name}**: ${t.steps.map((s) => s.name).join(", ")}`)
84+
.join("\n");
85+
86+
return {
87+
content: [{
88+
type: "text",
89+
text: `Visualization for "${program.name}" created!\n\n**URL:** ${shareUrl}\n\n**Schedule:** ${trackCount} tracks, ${stepCount} steps (${program.environmentType || "general"})\n\n**Tracks:**\n${tracksSummary}`,
90+
}],
91+
};
92+
}
93+
94+
return { content: [{ type: "text", text: `Error creating share link: ${shareResp.status}` }] };
95+
} catch (e) {
96+
return { content: [{ type: "text", text: `Error: ${e.message || e}` }] };
97+
}
98+
},
99+
);
100+
101+
server.tool(
102+
"import_from_source",
103+
"Import a recipe or protocol from external sources. Sources: spoonacular (recipes, preferred), themealdb (recipes, fallback), protocolsio (lab protocols). Actions: search, import, random.",
104+
{
105+
source: z.enum(["themealdb", "protocolsio", "spoonacular"]),
106+
action: z.enum(["search", "import", "random"]),
107+
query: z.string().optional(),
108+
},
109+
async ({ source, action, query }) => {
110+
try {
111+
let url, options;
112+
if (action === "search") {
113+
url = `${API_BASE}/api/import/search`;
114+
options = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source, query }) };
115+
} else if (action === "import") {
116+
url = `${API_BASE}/api/import`;
117+
options = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source, query }) };
118+
} else {
119+
url = `${API_BASE}/api/import/random`;
120+
options = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source }) };
121+
}
122+
const resp = await fetch(url, { ...options, signal: AbortSignal.timeout(15000) });
123+
if (!resp.ok) return { content: [{ type: "text", text: `Error (${resp.status}): ${await resp.text()}` }] };
124+
const data = await resp.json();
125+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
126+
} catch (e) {
127+
return { content: [{ type: "text", text: `Error: ${e.message || e}` }] };
128+
}
129+
},
130+
);
131+
132+
server.tool(
133+
"create_environment",
134+
"Create a Rhylthyme environment definition with resource constraints for a workspace (lab, kitchen, bakery, etc.).",
135+
{
136+
name: z.string(),
137+
type: z.enum(["kitchen", "laboratory", "bakery", "airport", "restaurant", "manufacturing", "hospital", "workshop", "general"]),
138+
description: z.string().optional(),
139+
resourceConstraints: z.array(z.object({
140+
task: z.string(),
141+
maxConcurrent: z.number().int().min(1),
142+
description: z.string().optional(),
143+
})),
144+
},
145+
async ({ name, type, description, resourceConstraints }) => {
146+
const envId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
147+
const env = { environmentId: envId, name, description: description || "", type, resourceConstraints, actors: 1 };
148+
const summary = resourceConstraints.map((rc) => ` - ${rc.task}: max ${rc.maxConcurrent}`).join("\n");
149+
return {
150+
content: [{
151+
type: "text",
152+
text: `Environment "${name}" created!\n\n**Type:** ${type}\n**Resources:**\n${summary}\n\n\`\`\`json\n${JSON.stringify(env, null, 2)}\n\`\`\``,
153+
}],
154+
};
155+
},
156+
);
157+
},
158+
{ serverInfo: { name: "rhylthyme-mcp", version: "1.0.0" } },
159+
{ basePath: "" },
160+
);
161+
162+
return webHandler;
163+
})();
164+
}
165+
return handlerPromise;
166+
}
167+
168+
// Vercel serverless handler — converts Node.js req/res to Web API Request/Response
169+
module.exports = async function handler(req, res) {
170+
const webHandler = await getHandler();
171+
172+
const protocol = req.headers["x-forwarded-proto"] || "https";
173+
const host = req.headers["x-forwarded-host"] || req.headers.host || "www.rhylthyme.com";
174+
const url = `${protocol}://${host}${req.url}`;
175+
176+
let body = undefined;
177+
if (req.method !== "GET" && req.method !== "HEAD") {
178+
body = await new Promise((resolve) => {
179+
const chunks = [];
180+
req.on("data", (chunk) => chunks.push(chunk));
181+
req.on("end", () => resolve(Buffer.concat(chunks)));
182+
});
183+
}
184+
185+
const webRequest = new Request(url, {
186+
method: req.method,
187+
headers: Object.fromEntries(
188+
Object.entries(req.headers).filter(([, v]) => v !== undefined).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v])
189+
),
190+
body: body,
191+
duplex: "half",
192+
});
193+
194+
try {
195+
const webResponse = await webHandler(webRequest);
196+
197+
res.statusCode = webResponse.status;
198+
for (const [key, value] of webResponse.headers.entries()) {
199+
res.setHeader(key, value);
200+
}
201+
202+
if (webResponse.body) {
203+
const reader = webResponse.body.getReader();
204+
while (true) {
205+
const { done, value } = await reader.read();
206+
if (done) { res.end(); break; }
207+
res.write(value);
208+
}
209+
} else {
210+
res.end();
211+
}
212+
} catch (e) {
213+
console.error("MCP handler error:", e);
214+
res.statusCode = 500;
215+
res.setHeader("Content-Type", "application/json");
216+
res.end(JSON.stringify({ error: e.message || "Internal server error" }));
217+
}
218+
};

0 commit comments

Comments
 (0)