-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.ts
More file actions
153 lines (141 loc) · 5.66 KB
/
server.ts
File metadata and controls
153 lines (141 loc) · 5.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { createRequire } from "node:module";
import AjvLib from "ajv";
import { z } from "zod";
const require = createRequire(import.meta.url);
const formSchema = require("@schepta/factories/schemas/form-schema.json") as Record<string, unknown>;
// Compile AJV validator once at module load
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AjvClass = (AjvLib as any).default ?? AjvLib;
const ajv = new AjvClass({ allErrors: true });
const validate = ajv.compile(formSchema);
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
const BASE_RESOURCE_URI = "ui://schepta/schepta.html";
/** Reads build id for cache-busting so Claude/host gets fresh UI after each build. */
async function getResourceUri(): Promise<string> {
try {
const raw = await fs.readFile(path.join(DIST_DIR, "build-id.json"), "utf-8");
const { id } = JSON.parse(raw) as { id?: string };
if (id) return `${BASE_RESOURCE_URI}?b=${id}`;
} catch {
// no build-id.json yet (e.g. first run without build)
}
return BASE_RESOURCE_URI;
}
/**
* Creates a new MCP server instance with Schepta form tools and resource.
*/
export async function createServer(): Promise<McpServer> {
const server = new McpServer({
name: "Schepta MCP Server",
version: "1.0.0",
});
const resourceUri = await getResourceUri();
// ── Tool 1: get_form_schema — returns the Schepta JSON Schema ──
registerAppTool(
server,
"get_form_schema",
{
title: "Get Form Schema",
description:
"Returns the Schepta form JSON Schema. Use this to understand valid form structure before building an instance. After understanding the schema, build an instance and call validate_schema to verify it, then preview_form to render it.",
inputSchema: z.object({}),
_meta: {},
},
async (): Promise<CallToolResult> => {
return {
content: [{ type: "text", text: JSON.stringify(formSchema, null, 2) }],
structuredContent: formSchema,
};
}
);
// ── Tool 2: validate_schema — validates a form instance against the schema ──
registerAppTool(
server,
"validate_schema",
{
title: "Validate Schema",
description:
"Validates a Schepta form instance against the JSON Schema. Returns { valid: boolean, errors: string[] }. If valid is true, call preview_form with the same instance to render it. If false, fix the errors and try again.",
inputSchema: z.object({
instance: z.record(z.string(), z.unknown()).describe("The form instance to validate"),
}),
outputSchema: z.object({
valid: z.boolean(),
errors: z.array(z.string()),
}),
_meta: {},
},
async (args): Promise<CallToolResult> => {
const instance = args.instance as Record<string, unknown>;
const valid = validate(instance) as boolean;
const errors = validate.errors
? validate.errors.map((e: { instancePath?: string; message?: string }) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`)
: [];
return {
content: [
{
type: "text",
text: valid
? "Instance is valid. Call preview_form with this instance to render the form."
: `Instance is invalid. Errors:\n${errors.map((e: string) => `- ${e}`).join("\n")}`,
},
],
structuredContent: { valid, errors },
};
}
);
// ── Tool 3: preview_form — opens the widget and renders the form ──
registerAppTool(
server,
"preview_form",
{
title: "Preview Form",
description:
"Renders a valid Schepta form instance in the MCP Apps widget. The instance must have been validated with validate_schema first. The widget will display the form visually.",
inputSchema: z.object({
instance: z.record(z.string(), z.unknown()).describe("The validated form instance to render"),
}),
outputSchema: z.object({
status: z.string(),
message: z.string(),
}),
_meta: { ui: { resourceUri } },
},
async (args): Promise<CallToolResult> => {
const instance = args.instance as Record<string, unknown>;
if (!instance || typeof instance["x-component"] !== "string" || typeof instance.$id !== "string") {
return {
content: [{ type: "text", text: "Invalid instance: must have $id and x-component fields. Run validate_schema first." }],
structuredContent: { status: "error", message: "Missing $id or x-component in instance" },
isError: true,
};
}
return {
content: [{ type: "text", text: `Form preview opened for schema $id: "${instance.$id}". The widget is rendering the form.` }],
structuredContent: { status: "started", message: "Form preview opened" },
};
}
);
// ── Resource: serve the bundled schepta HTML widget ──
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(path.join(DIST_DIR, "schepta.html"), "utf-8");
return {
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
};
}
);
return server;
}