kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { randomUUID } from "node:crypto";
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4import { Hono } from "hono";
5import { auth } from "../auth";
6import {
7 createAuthCode,
8 exchangeCode,
9 getClient,
10 registerClient,
11} from "./oauth";
12import { registerMcpTools } from "./tools";
13
14const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173";
15const apiUrl = (process.env.KANEO_API_URL || "http://localhost:1337").replace(
16 /\/api\/?$/,
17 "",
18);
19
20const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
21
22function createMcpServerForUser(token: string): McpServer {
23 const server = new McpServer({
24 name: "kaneo-mcp",
25 version: "1.0.0",
26 });
27 registerMcpTools(server, apiUrl, token);
28 return server;
29}
30
31async function validateBearerToken(
32 req: Request,
33): Promise<{ userId: string; token: string } | null> {
34 const authHeader = req.headers.get("authorization");
35 if (!authHeader) return null;
36 const match = authHeader.match(/^Bearer\s+(\S+)$/i);
37 if (!match?.[1]) return null;
38 const token = match[1];
39
40 const headers = new Headers();
41 headers.set("authorization", `Bearer ${token}`);
42 const session = await auth.api.getSession({ headers });
43
44 if (!session?.user?.id) return null;
45 return { userId: session.user.id, token };
46}
47
48const mcp = new Hono();
49
50mcp.post("/mcp/register", async (c) => {
51 const body = await c.req.json();
52 const client = registerClient({
53 redirectUris: body.redirect_uris ?? [],
54 clientName: body.client_name,
55 });
56 return c.json({
57 client_id: client.clientId,
58 client_id_issued_at: client.issuedAt,
59 redirect_uris: client.redirectUris,
60 client_name: client.clientName,
61 token_endpoint_auth_method: body.token_endpoint_auth_method ?? "none",
62 grant_types: body.grant_types ?? ["authorization_code"],
63 response_types: body.response_types ?? ["code"],
64 });
65});
66
67mcp.get("/mcp/authorize", async (c) => {
68 const clientId = c.req.query("client_id");
69 const redirectUri = c.req.query("redirect_uri");
70 const codeChallenge = c.req.query("code_challenge");
71 const state = c.req.query("state");
72
73 if (!clientId || !redirectUri || !codeChallenge) {
74 return c.json({ error: "invalid_request" }, 400);
75 }
76
77 const client = getClient(clientId);
78 if (!client) {
79 return c.json({ error: "invalid_client" }, 400);
80 }
81
82 const session = await auth.api.getSession({
83 headers: c.req.raw.headers,
84 });
85
86 if (session?.user?.id) {
87 const code = createAuthCode({
88 clientId,
89 userId: session.user.id,
90 codeChallenge,
91 redirectUri,
92 });
93 const url = new URL(redirectUri);
94 url.searchParams.set("code", code);
95 if (state) url.searchParams.set("state", state);
96 return c.redirect(url.toString());
97 }
98
99 const deviceRes = await fetch(`${apiUrl}/api/auth/device/code`, {
100 method: "POST",
101 headers: { "Content-Type": "application/json" },
102 body: JSON.stringify({ client_id: "kaneo-mcp" }),
103 });
104
105 if (!deviceRes.ok) {
106 return c.json({ error: "device_code_failed" }, 500);
107 }
108
109 const device = (await deviceRes.json()) as {
110 device_code: string;
111 user_code: string;
112 verification_uri: string;
113 interval: number;
114 expires_in: number;
115 };
116
117 const devicePageUrl = `${clientUrl}/device?user_code=${encodeURIComponent(device.user_code)}`;
118
119 return c.html(`<!DOCTYPE html>
120<html lang="en">
121<head>
122 <meta charset="utf-8">
123 <meta name="viewport" content="width=device-width, initial-scale=1">
124 <title>Kaneo MCP</title>
125 <script>window.location.href = ${JSON.stringify(devicePageUrl)};</script>
126</head>
127<body>Redirecting to Kaneo…</body>
128<script>
129 const deviceCode = ${JSON.stringify(device.device_code)};
130 const interval = ${device.interval} * 1000;
131 const apiUrl = ${JSON.stringify(apiUrl)};
132 const redirectUri = ${JSON.stringify(redirectUri)};
133 const codeChallenge = ${JSON.stringify(codeChallenge)};
134 const clientId = ${JSON.stringify(clientId)};
135 const state = ${JSON.stringify(state || "")};
136
137 async function poll() {
138 try {
139 const res = await fetch(apiUrl + "/api/auth/device/token", {
140 method: "POST",
141 headers: { "Content-Type": "application/json" },
142 body: JSON.stringify({
143 grant_type: "urn:ietf:params:oauth:grant-type:device_code",
144 device_code: deviceCode,
145 client_id: "kaneo-mcp"
146 })
147 });
148 const data = await res.json();
149
150 if (res.ok && data.access_token) {
151 const codeRes = await fetch(apiUrl + "/api/mcp/authorize/callback", {
152 method: "POST",
153 headers: { "Content-Type": "application/json" },
154 body: JSON.stringify({
155 access_token: data.access_token,
156 client_id: clientId,
157 code_challenge: codeChallenge,
158 redirect_uri: redirectUri,
159 state: state
160 })
161 });
162 const codeData = await codeRes.json();
163 if (codeData.redirect) window.location.href = codeData.redirect;
164 return;
165 }
166
167 if (data.error === "authorization_pending" || data.error === "slow_down") {
168 setTimeout(poll, data.error === "slow_down" ? interval + 5000 : interval);
169 return;
170 }
171 } catch {
172 setTimeout(poll, interval);
173 }
174 }
175
176 setTimeout(poll, interval);
177</script>
178</html>`);
179});
180
181mcp.post("/mcp/authorize/callback", async (c) => {
182 const body = await c.req.json();
183 const { access_token, client_id, code_challenge, redirect_uri, state } = body;
184
185 if (!access_token || !client_id || !code_challenge || !redirect_uri) {
186 return c.json({ error: "invalid_request" }, 400);
187 }
188
189 const headers = new Headers();
190 headers.set("authorization", `Bearer ${access_token}`);
191 const session = await auth.api.getSession({ headers });
192
193 if (!session?.user?.id) {
194 return c.json({ error: "invalid_token" }, 401);
195 }
196
197 const code = createAuthCode({
198 clientId: client_id,
199 userId: session.user.id,
200 codeChallenge: code_challenge,
201 redirectUri: redirect_uri,
202 });
203
204 const url = new URL(redirect_uri);
205 url.searchParams.set("code", code);
206 if (state) url.searchParams.set("state", state);
207
208 return c.json({ redirect: url.toString() });
209});
210
211mcp.post("/mcp/token", async (c) => {
212 const contentType = c.req.header("content-type") || "";
213 let params: Record<string, string>;
214
215 if (contentType.includes("application/x-www-form-urlencoded")) {
216 const body = await c.req.text();
217 params = Object.fromEntries(new URLSearchParams(body));
218 } else {
219 params = await c.req.json();
220 }
221
222 const { grant_type, code, client_id, code_verifier, redirect_uri } = params;
223
224 if (grant_type !== "authorization_code") {
225 return c.json({ error: "unsupported_grant_type" }, 400);
226 }
227 if (!code || !client_id || !code_verifier || !redirect_uri) {
228 return c.json({ error: "invalid_request" }, 400);
229 }
230
231 const result = await exchangeCode(
232 code,
233 client_id,
234 code_verifier,
235 redirect_uri,
236 );
237 if (!result) {
238 return c.json({ error: "invalid_grant" }, 400);
239 }
240
241 return c.json({
242 access_token: result.accessToken,
243 token_type: "bearer",
244 expires_in: result.expiresIn,
245 });
246});
247
248mcp.get("/.well-known/oauth-protected-resource/api/mcp", (c) =>
249 c.json({
250 resource: `${apiUrl}/api/mcp`,
251 authorization_servers: [`${apiUrl}/api`],
252 }),
253);
254
255mcp.get("/.well-known/oauth-authorization-server/api", (c) =>
256 c.json({
257 issuer: `${apiUrl}/api`,
258 authorization_endpoint: `${apiUrl}/api/mcp/authorize`,
259 token_endpoint: `${apiUrl}/api/mcp/token`,
260 registration_endpoint: `${apiUrl}/api/mcp/register`,
261 response_types_supported: ["code"],
262 grant_types_supported: ["authorization_code"],
263 code_challenge_methods_supported: ["S256"],
264 token_endpoint_auth_methods_supported: ["none"],
265 }),
266);
267
268mcp.all("/mcp", async (c) => {
269 const authResult = await validateBearerToken(c.req.raw);
270 if (!authResult) {
271 const prmUrl = `${apiUrl}/api/.well-known/oauth-protected-resource/api/mcp`;
272 c.header("WWW-Authenticate", `Bearer resource_metadata="${prmUrl}"`);
273 return c.json(
274 {
275 error: "invalid_token",
276 error_description: "Missing or invalid token",
277 },
278 401,
279 );
280 }
281
282 const sessionId = c.req.header("mcp-session-id");
283
284 if (sessionId) {
285 const existing = sessions.get(sessionId);
286 if (existing) {
287 return existing.handleRequest(c.req.raw);
288 }
289 return c.json({ error: "Session not found" }, 404);
290 }
291
292 if (c.req.method !== "POST") {
293 return c.json({ error: "Method not allowed" }, 405);
294 }
295
296 const transport = new WebStandardStreamableHTTPServerTransport({
297 sessionIdGenerator: () => randomUUID(),
298 });
299
300 transport.onclose = () => {
301 if (transport.sessionId) {
302 sessions.delete(transport.sessionId);
303 }
304 };
305
306 const server = createMcpServerForUser(authResult.token);
307 await server.connect(transport);
308 const response = await transport.handleRequest(c.req.raw);
309
310 if (transport.sessionId) {
311 sessions.set(transport.sessionId, transport);
312 }
313
314 return response;
315});
316
317export default mcp;
318
319export function mcpWellKnownRoutes(baseUrl: string) {
320 const wellKnown = new Hono();
321
322 wellKnown.get("/.well-known/oauth-protected-resource/api/mcp", (c) =>
323 c.json({
324 resource: `${baseUrl}/api/mcp`,
325 authorization_servers: [`${baseUrl}/api`],
326 }),
327 );
328
329 wellKnown.get("/.well-known/oauth-authorization-server/api", (c) =>
330 c.json({
331 issuer: `${baseUrl}/api`,
332 authorization_endpoint: `${baseUrl}/api/mcp/authorize`,
333 token_endpoint: `${baseUrl}/api/mcp/token`,
334 registration_endpoint: `${baseUrl}/api/mcp/register`,
335 response_types_supported: ["code"],
336 grant_types_supported: ["authorization_code"],
337 code_challenge_methods_supported: ["S256"],
338 token_endpoint_auth_methods_supported: ["none"],
339 }),
340 );
341
342 return wellKnown;
343}