wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files e45e4989a79ef3f801c453528dd5b471f91bea4f
155 lines · 4.8 KB · ~1.4k tok Raw History
1
/**
2
 * An MCP server over plain HTTP: `POST /mcp` takes one JSON-RPC 2.0
3
 * message and answers with JSON (streamable HTTP without the stream —
4
 * nothing here needs to push). The tools are the actions, called with the
5
 * same identity the page gets, so an agent speaking MCP through the forge
6
 * is the person whose grant it carries, and a bare one is anonymous.
7
 */
8
9
import type { Database } from "bun:sqlite";
10
import {
11
  deleteDrawing,
12
  getDrawing,
13
  listDrawings,
14
  startDrawing,
15
  svgsOf,
16
  type Drawing,
17
  type Result,
18
} from "./actions.ts";
19
import { GLOW, INK, KINDS, PROMPT_MAX } from "./glyph.ts";
20
import { title } from "./env.ts";
21
import { fields } from "./http.ts";
22
import type { Who } from "./who.ts";
23
24
interface Tool {
25
  readonly name: string;
26
  readonly description: string;
27
  readonly inputSchema: Record<string, unknown>;
28
  readonly run: (
29
    db: Database,
30
    who: Who,
31
    args: Record<string, unknown>,
32
  ) => Result<unknown>;
33
}
34
35
/** A drawing as a tool answers it: its SVGs as an array. */
36
const shaped = <T extends Drawing | Drawing[]>(r: Result<T>): Result<unknown> =>
37
  r.ok
38
    ? {
39
        ok: true,
40
        value: Array.isArray(r.value)
41
          ? r.value.map((d) => ({ ...d, svgs: svgsOf(d) }))
42
          : { ...(r.value as Drawing), svgs: svgsOf(r.value as Drawing) },
43
      }
44
    : r;
45
46
export const tools: readonly Tool[] = [
47
  {
48
    name: "draw",
49
    description: `Ask Claude for SVG concepts from a prompt. Answers at once with status \`drawing\`; call get_drawing until it is \`done\` (about a minute). The SVGs use ${INK} as ink and ${GLOW} as glow: swap those two for your own colours.`,
50
    inputSchema: {
51
      type: "object",
52
      properties: {
53
        prompt: { type: "string", maxLength: PROMPT_MAX },
54
        kind: { enum: Object.keys(KINDS), description: "What to draw (mark)." },
55
      },
56
      required: ["prompt"],
57
    },
58
    run: (db, who, args) =>
59
      shaped(
60
        startDrawing(db, who, {
61
          prompt: String(args.prompt ?? ""),
62
          ...(args.kind === undefined ? {} : { kind: String(args.kind) }),
63
        }),
64
      ),
65
  },
66
  {
67
    name: "get_drawing",
68
    description: "One drawing by id, with its SVGs once it is done.",
69
    inputSchema: {
70
      type: "object",
71
      properties: { id: { type: "integer" } },
72
      required: ["id"],
73
    },
74
    run: (db, who, args) => shaped(getDrawing(db, who, { id: Number(args.id) })),
75
  },
76
  {
77
    name: "list_drawings",
78
    description: "Your drawings, newest first.",
79
    inputSchema: {
80
      type: "object",
81
      properties: {
82
        limit: { type: "integer", minimum: 1, maximum: 200, description: "At most this many (50)." },
83
      },
84
    },
85
    run: (db, who, args) => shaped(listDrawings(db, who, { limit: Number(args.limit ?? 50) })),
86
  },
87
  {
88
    name: "delete_drawing",
89
    description: "Remove a drawing by id. Only whoever asked for it, or an admin, can.",
90
    inputSchema: {
91
      type: "object",
92
      properties: { id: { type: "integer" } },
93
      required: ["id"],
94
    },
95
    run: (db, who, args) => deleteDrawing(db, who, { id: Number(args.id) }),
96
  },
97
];
98
99
/** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */
100
export function rpc(
101
  db: Database,
102
  who: Who,
103
  message: unknown,
104
): { status: number; body?: unknown } {
105
  const m = fields(message);
106
  const id = m.id ?? null;
107
  const reply = (result: unknown) => ({
108
    status: 200,
109
    body: { jsonrpc: "2.0", id, result },
110
  });
111
  const refuse = (code: number, text: string) => ({
112
    status: 200,
113
    body: { jsonrpc: "2.0", id, error: { code, message: text } },
114
  });
115
  if (m.jsonrpc !== "2.0" || typeof m.method !== "string")
116
    return refuse(-32600, "not a JSON-RPC 2.0 request");
117
  if (m.id === undefined) return { status: 202 };
118
  const params = fields(m.params);
119
  switch (m.method) {
120
    case "initialize":
121
      return reply({
122
        protocolVersion: "2025-06-18",
123
        capabilities: { tools: {} },
124
        serverInfo: { name: title, version: "1" },
125
      });
126
    case "ping":
127
      return reply({});
128
    case "tools/list":
129
      return reply({
130
        tools: tools.map(({ name, description, inputSchema }) => ({
131
          name,
132
          description,
133
          inputSchema,
134
        })),
135
      });
136
    case "tools/call": {
137
      const tool = tools.find((t) => t.name === params.name);
138
      if (tool === undefined)
139
        return refuse(-32602, `no tool named ${String(params.name)}`);
140
      const result = tool.run(db, who, fields(params.arguments));
141
      return reply(
142
        result.ok
143
          ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
144
          : {
145
              content: [
146
                { type: "text", text: `${result.error}: ${result.message}` },
147
              ],
148
              isError: true,
149
            },
150
      );
151
    }
152
    default:
153
      return refuse(-32601, `no method ${m.method}`);
154
  }
155
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts