kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 343 lines 10 kB view raw
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}