tangled
alpha
login
or
join now
graham.systems
/
cistern
3
fork
atom
Encrypted, ephemeral, private memos on atproto
3
fork
atom
overview
issues
pulls
pipelines
feat(mcp): enable cors, move to hono
graham.systems
4 months ago
cda61a39
6dd67986
verified
This commit was signed with the committer's
known signature
.
graham.systems
SSH Key Fingerprint:
SHA256:Fvaam8TgCBeBlr/Fo7eA6VGAIAWmzjwUqUTw5o6anWA=
+148
-81
4 changed files
expand all
collapse all
unified
split
deno.lock
packages
mcp
deno.jsonc
hono.ts
index.ts
+5
-6
deno.lock
···
1
1
{
2
2
"version": "5",
3
3
"specifiers": {
4
4
+
"jsr:@hono/hono@^4.10.5": "4.10.5",
4
5
"jsr:@logtape/logtape@^1.2.0": "1.2.0",
5
6
"jsr:@noble/ciphers@^2.0.1": "2.0.1",
6
7
"jsr:@noble/curves@2.0": "2.0.1",
···
28
29
"npm:zod@^3.25.76": "3.25.76"
29
30
},
30
31
"jsr": {
32
32
+
"@hono/hono@4.10.5": {
33
33
+
"integrity": "13dbf2a528feb8189ad13394b213f0cf5f83b0ba4b2fadd0549993426db9ad2d"
34
34
+
},
31
35
"@logtape/logtape@1.2.0": {
32
36
"integrity": "8e1d3af5c91966cc5689cfb17081a36bccfdff28ff6314769185661f5147e74d"
33
37
},
···
52
56
},
53
57
"@puregarlic/randimal@1.1.1": {
54
58
"integrity": "4e1fa61982cf2f610e9ad851d0fd0ff7bc3bb7b7a3c6cccae59f5ae2e68a7e47"
55
55
-
},
56
56
-
"@std/assert@1.0.14": {
57
57
-
"integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4",
58
58
-
"dependencies": [
59
59
-
"jsr:@std/internal@^1.0.10"
60
60
-
]
61
59
},
62
60
"@std/assert@1.0.15": {
63
61
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
···
1724
1722
},
1725
1723
"packages/mcp": {
1726
1724
"dependencies": [
1725
1725
+
"jsr:@hono/hono@^4.10.5",
1727
1726
"jsr:@logtape/logtape@^1.2.0",
1728
1727
"jsr:@std/cli@^1.0.23",
1729
1728
"npm:@modelcontextprotocol/sdk@^1.21.1",
+1
packages/mcp/deno.jsonc
···
17
17
}
18
18
},
19
19
"imports": {
20
20
+
"hono": "jsr:@hono/hono@^4.10.5",
20
21
"@logtape/logtape": "jsr:@logtape/logtape@^1.2.0",
21
22
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.21.1",
22
23
"@std/cli": "jsr:@std/cli@^1.0.23",
+124
packages/mcp/hono.ts
···
1
1
+
import { Hono } from "hono";
2
2
+
import { cors } from "hono/cors";
3
3
+
import { getLogger, withContext } from "@logtape/logtape";
4
4
+
import { toFetchResponse, toReqRes } from "fetch-to-node";
5
5
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
6
+
import { createServer } from "./server.ts";
7
7
+
8
8
+
export function createApp() {
9
9
+
const app = new Hono();
10
10
+
const logger = getLogger(["cistern", "http"]);
11
11
+
const sessions = new Map<string, StreamableHTTPServerTransport>();
12
12
+
13
13
+
app.use("*", async (c, next) => {
14
14
+
const requestId = crypto.randomUUID();
15
15
+
const startTime = Date.now();
16
16
+
17
17
+
await withContext({
18
18
+
requestId,
19
19
+
method: c.req.method,
20
20
+
url: c.req.url,
21
21
+
userAgent: c.req.header("User-Agent"),
22
22
+
ipAddress: c.req.header("CF-Connecting-IP") ||
23
23
+
c.req.header("X-Forwarded-For"),
24
24
+
}, async () => {
25
25
+
logger.info("{method} request started", {
26
26
+
method: c.req.method,
27
27
+
url: c.req.url,
28
28
+
requestId,
29
29
+
});
30
30
+
31
31
+
await next();
32
32
+
33
33
+
const duration = Date.now() - startTime;
34
34
+
35
35
+
logger.info("{status} request completed in {duration}ms", {
36
36
+
status: c.res.status,
37
37
+
duration,
38
38
+
requestId,
39
39
+
});
40
40
+
});
41
41
+
});
42
42
+
43
43
+
app.onError((err, c) => {
44
44
+
logger.error("request error", {
45
45
+
error: {
46
46
+
name: err.name,
47
47
+
message: err.message,
48
48
+
stack: err.stack,
49
49
+
},
50
50
+
method: c.req.method,
51
51
+
url: c.req.url,
52
52
+
});
53
53
+
54
54
+
return c.json({ error: "internal server error" }, 500);
55
55
+
});
56
56
+
57
57
+
app.all(
58
58
+
"/mcp",
59
59
+
cors({
60
60
+
origin: "*",
61
61
+
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
62
62
+
allowHeaders: [
63
63
+
"Content-Type",
64
64
+
"Authorization",
65
65
+
"Mcp-Session-Id",
66
66
+
"Mcp-Protocol-Version",
67
67
+
],
68
68
+
exposeHeaders: ["Mcp-Session-Id"],
69
69
+
}),
70
70
+
);
71
71
+
72
72
+
app.post("/mcp", async (ctx) => {
73
73
+
const sessionId = ctx.req.header("mcp-session-id") ?? crypto.randomUUID();
74
74
+
let session = sessions.get(sessionId);
75
75
+
76
76
+
if (session) {
77
77
+
logger.info("resuming session {sessionId}", { sessionId });
78
78
+
} else {
79
79
+
logger.info("creating new session {sessionId}", { sessionId });
80
80
+
81
81
+
const server = createServer();
82
82
+
83
83
+
session = new StreamableHTTPServerTransport({
84
84
+
sessionIdGenerator: () => sessionId,
85
85
+
});
86
86
+
87
87
+
session.onclose = () => {
88
88
+
logger.info("closing session {sessionId}", { sessionId });
89
89
+
};
90
90
+
91
91
+
await server.connect(session);
92
92
+
93
93
+
sessions.set(sessionId, session);
94
94
+
}
95
95
+
96
96
+
const { req, res } = toReqRes(ctx.req.raw);
97
97
+
98
98
+
await session.handleRequest(req, res);
99
99
+
100
100
+
return await toFetchResponse(res);
101
101
+
});
102
102
+
103
103
+
app.on(["GET", "DELETE"], "/mcp", async (ctx) => {
104
104
+
const sessionId = ctx.req.header("mcp-session-id") ?? "";
105
105
+
const session = sessions.get(sessionId);
106
106
+
107
107
+
if (!session) {
108
108
+
logger.info("{method} invalid session {sessionId}", {
109
109
+
method: ctx.req.method,
110
110
+
sessionId,
111
111
+
});
112
112
+
113
113
+
return ctx.json({ error: "invalid or missing session" }, 401);
114
114
+
}
115
115
+
116
116
+
const { req, res } = toReqRes(ctx.req.raw);
117
117
+
118
118
+
await session.handleRequest(req, res);
119
119
+
120
120
+
return await toFetchResponse(res);
121
121
+
});
122
122
+
123
123
+
return app;
124
124
+
}
+18
-75
packages/mcp/index.ts
···
1
1
import { parseArgs } from "@std/cli";
2
2
+
import { AsyncLocalStorage } from "node:async_hooks";
2
3
import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
3
4
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
5
-
import { toFetchResponse, toReqRes } from "fetch-to-node";
6
5
7
6
import { createServer } from "./server.ts";
7
7
+
import { createApp } from "./hono.ts";
8
8
9
9
async function main() {
10
10
await configure({
11
11
sinks: { console: getConsoleSink() },
12
12
loggers: [
13
13
-
{ category: "cistern-mcp", lowestLevel: "trace", sinks: ["console"] },
13
13
+
{
14
14
+
category: ["cistern", "mcp"],
15
15
+
lowestLevel: "trace",
16
16
+
sinks: ["console"],
17
17
+
},
18
18
+
{
19
19
+
category: ["cistern", "http"],
20
20
+
lowestLevel: "info",
21
21
+
sinks: ["console"],
22
22
+
},
14
23
],
24
24
+
contextLocalStorage: new AsyncLocalStorage(),
15
25
});
16
26
17
17
-
const logger = getLogger("cistern-mcp");
27
27
+
const logger = getLogger(["cistern", "mcp"]);
18
28
const args = parseArgs(Deno.args, {
19
29
boolean: ["http"],
20
30
});
21
31
22
22
-
const server = createServer();
23
23
-
24
32
if (!args.http) {
25
33
logger.info("starting in stdio mode");
26
34
27
35
const transport = new StdioServerTransport();
36
36
+
const server = createServer();
37
37
+
28
38
await server.connect(transport);
29
39
} else {
30
40
logger.info("starting in streamable HTTP mode");
31
41
32
32
-
const sessions: Map<string, StreamableHTTPServerTransport> = new Map();
42
42
+
const app = createApp();
33
43
34
44
Deno.serve(
35
45
{
···
38
48
...addr,
39
49
});
40
50
},
41
41
-
onError(error) {
42
42
-
logger.error(
43
43
-
"unexpected route error: {error}",
44
44
-
{ error },
45
45
-
);
46
46
-
47
47
-
return new Response(null, { status: 500 });
48
48
-
},
49
51
},
50
50
-
async function handler(request: Request): Promise<Response> {
51
51
-
const PATH = new URLPattern({ pathname: "/mcp" });
52
52
-
53
53
-
if (!PATH.exec(request.url)) {
54
54
-
logger.info("not found", {
55
55
-
status: 404,
56
56
-
url: request.url,
57
57
-
});
58
58
-
59
59
-
return new Response(null, { status: 404 });
60
60
-
}
61
61
-
62
62
-
const sessionId = request.headers.get("mcp-session-id");
63
63
-
let transport: StreamableHTTPServerTransport;
64
64
-
65
65
-
if (sessionId && sessions.has(sessionId)) {
66
66
-
logger.info("{method} resuming session {sessionId}", {
67
67
-
sessionId,
68
68
-
method: request.method,
69
69
-
});
70
70
-
71
71
-
transport = sessions.get(sessionId)!;
72
72
-
} else if (
73
73
-
request.method !== "POST" && !sessions.has(sessionId ?? "")
74
74
-
) {
75
75
-
logger.error("{method} has invalid session {sessionId}", {
76
76
-
sessionId,
77
77
-
method: request.method,
78
78
-
});
79
79
-
80
80
-
return Response.json({ error: "invalid or missing session" }, {
81
81
-
status: 401,
82
82
-
});
83
83
-
} else {
84
84
-
const sessionId = crypto.randomUUID();
85
85
-
86
86
-
logger.info("opening new session {sessionId}", { sessionId });
87
87
-
88
88
-
transport = new StreamableHTTPServerTransport({
89
89
-
sessionIdGenerator: () => sessionId as string,
90
90
-
});
91
91
-
92
92
-
transport.onclose = () => {
93
93
-
logger.info("session {sessionId} closed, cleaning up", {
94
94
-
sessionId,
95
95
-
});
96
96
-
sessions.delete(sessionId);
97
97
-
};
98
98
-
99
99
-
sessions.set(sessionId, transport);
100
100
-
101
101
-
await server.connect(transport);
102
102
-
}
103
103
-
104
104
-
const { req, res } = toReqRes(request);
105
105
-
106
106
-
await transport.handleRequest(req, res);
107
107
-
108
108
-
return await toFetchResponse(res);
109
109
-
},
52
52
+
app.fetch,
110
53
);
111
54
}
112
55
}