@warlok-net/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @warlok-net/mcp-server
2
+
3
+ MCP server for [Warlok](https://warlok.net) — gives agents the ability to summon
4
+ game-ready, auto-rigged 3D assets from images or text prompts.
5
+
6
+ ## Setup
7
+
8
+ Create an API key at [warlok.net/settings/api-keys](https://warlok.net/settings/api-keys), then:
9
+
10
+ ```json
11
+ {
12
+ "mcpServers": {
13
+ "warlok": {
14
+ "command": "npx",
15
+ "args": ["-y", "@warlok-net/mcp-server"],
16
+ "env": {
17
+ "WARLOK_API_KEY": "<your_key>"
18
+ }
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ ## Tools
25
+
26
+ | Tool | Purpose |
27
+ | --- | --- |
28
+ | `generate_3d_model` | Summon a 3D asset from a prompt or image URL (1 credit = $1) |
29
+ | `get_model_status` | Poll stage/progress (~8 min end-to-end; poll every 10–15 s) |
30
+ | `list_models` | List the account's generations |
31
+ | `get_model_download_url` | Signed GLB/preview URLs for a completed asset |
32
+ | `delete_model` | Remove a generation |
33
+ | `capture_ui_screenshot` | Optional; requires a local Playwright install |
34
+
35
+ ## Alpha limits
36
+
37
+ Prepaid credits ($1/summon), 5 summons per rolling day, failed summons auto-refunded.
38
+ `402` → buy credits at [warlok.net/settings](https://warlok.net/settings); `429` → daily cap reached.
39
+
40
+ Docs: [warlok.net/docs/mcp](https://warlok.net/docs/mcp) · License: MIT
@@ -0,0 +1,678 @@
1
+ // src/index.ts
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ ErrorCode,
8
+ McpError
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+ import {
11
+ WarlokClient
12
+ } from "@warlok-net/sdk";
13
+
14
+ // src/screenshot.ts
15
+ import fs from "fs/promises";
16
+ import path from "path";
17
+ async function loadChromium() {
18
+ try {
19
+ const playwright = await import("playwright");
20
+ return playwright.chromium;
21
+ } catch {
22
+ throw new Error(
23
+ "capture_ui_screenshot requires Playwright. Install it with: npm i playwright && npx playwright install chromium"
24
+ );
25
+ }
26
+ }
27
+ var DEFAULT_VIEWPORT_WIDTH = 1440;
28
+ var DEFAULT_VIEWPORT_HEIGHT = 900;
29
+ var DEFAULT_TIMEOUT_MS = 3e4;
30
+ var DEFAULT_WAIT_MS = 250;
31
+ var DEFAULT_FORMAT = "png";
32
+ async function captureUiScreenshot(input) {
33
+ const startedAt = Date.now();
34
+ const url = parseHttpUrl(input.url);
35
+ const width = clampInteger(input.viewportWidth, 320, 7680, DEFAULT_VIEWPORT_WIDTH);
36
+ const height = clampInteger(
37
+ input.viewportHeight,
38
+ 240,
39
+ 4320,
40
+ DEFAULT_VIEWPORT_HEIGHT
41
+ );
42
+ const timeoutMs = clampInteger(input.timeoutMs, 5e3, 12e4, DEFAULT_TIMEOUT_MS);
43
+ const waitForMs = clampInteger(input.waitForMs, 0, 3e4, DEFAULT_WAIT_MS);
44
+ const fullPage = input.fullPage ?? true;
45
+ const colorScheme = input.colorScheme ?? "dark";
46
+ const format = input.format ?? inferFormatFromOutputPath(input.outputPath);
47
+ const outputPath = resolveOutputPath(url, input.outputPath, format);
48
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
49
+ const chromium = await loadChromium();
50
+ const browser = await chromium.launch({ headless: true });
51
+ try {
52
+ const context = await browser.newContext({
53
+ viewport: { width, height },
54
+ colorScheme,
55
+ deviceScaleFactor: 1
56
+ });
57
+ const page = await context.newPage();
58
+ page.setDefaultTimeout(timeoutMs);
59
+ await page.goto(url.toString(), { waitUntil: "networkidle", timeout: timeoutMs });
60
+ if (input.waitForSelector) {
61
+ await page.waitForSelector(input.waitForSelector, {
62
+ state: "visible",
63
+ timeout: timeoutMs
64
+ });
65
+ }
66
+ if (waitForMs > 0) {
67
+ await page.waitForTimeout(waitForMs);
68
+ }
69
+ if (input.selector) {
70
+ const locator = page.locator(input.selector).first();
71
+ await locator.waitFor({ state: "visible", timeout: timeoutMs });
72
+ await locator.screenshot({
73
+ path: outputPath,
74
+ type: format
75
+ });
76
+ } else {
77
+ await page.screenshot({
78
+ path: outputPath,
79
+ fullPage,
80
+ type: format
81
+ });
82
+ }
83
+ await context.close();
84
+ } finally {
85
+ await browser.close();
86
+ }
87
+ const stat = await fs.stat(outputPath);
88
+ return {
89
+ outputPath,
90
+ bytes: stat.size,
91
+ width,
92
+ height,
93
+ url: url.toString(),
94
+ selector: input.selector ?? null,
95
+ fullPage,
96
+ colorScheme,
97
+ format,
98
+ durationMs: Date.now() - startedAt
99
+ };
100
+ }
101
+ function parseHttpUrl(value) {
102
+ let parsed;
103
+ try {
104
+ parsed = new URL(value);
105
+ } catch {
106
+ throw new Error("Invalid URL. Expected a valid absolute URL.");
107
+ }
108
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
109
+ throw new Error("Invalid URL protocol. Only http/https URLs are supported.");
110
+ }
111
+ return parsed;
112
+ }
113
+ function resolveOutputPath(url, requestedPath, format) {
114
+ if (requestedPath) {
115
+ const absolute = path.isAbsolute(requestedPath) ? requestedPath : path.resolve(outputRoot(), requestedPath);
116
+ return ensureExt(absolute, format);
117
+ }
118
+ const now = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
119
+ const host = sanitizeSegment(url.host);
120
+ const pathname = sanitizeSegment(url.pathname.replace(/\//g, "_") || "root");
121
+ const filename = `${now}-${host}-${pathname}.${format}`;
122
+ return path.join(outputRoot(), filename);
123
+ }
124
+ function outputRoot() {
125
+ const configured = process.env.WARLOK_SCREENSHOT_DIR;
126
+ if (configured && configured.trim()) {
127
+ return path.resolve(configured.trim());
128
+ }
129
+ return path.resolve(process.cwd(), "tmp", "mcp-screenshots");
130
+ }
131
+ function ensureExt(value, format) {
132
+ const ext = path.extname(value).toLowerCase();
133
+ if (ext === ".png" || ext === ".jpg" || ext === ".jpeg" || ext === ".webp") {
134
+ return value;
135
+ }
136
+ return `${value}.${format}`;
137
+ }
138
+ function sanitizeSegment(value) {
139
+ return value.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "capture";
140
+ }
141
+ function inferFormatFromOutputPath(pathname) {
142
+ if (!pathname) return DEFAULT_FORMAT;
143
+ const ext = path.extname(pathname).toLowerCase();
144
+ if (ext === ".jpg" || ext === ".jpeg") return "jpeg";
145
+ return "png";
146
+ }
147
+ function clampInteger(value, min, max, fallback) {
148
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
149
+ const rounded = Math.round(value);
150
+ if (rounded < min) return min;
151
+ if (rounded > max) return max;
152
+ return rounded;
153
+ }
154
+
155
+ // src/index.ts
156
+ var TOOLS = [
157
+ {
158
+ name: "generate_3d_model",
159
+ description: "Generate a 3D model from text description or image. Returns an asset_id to track status.",
160
+ inputSchema: {
161
+ type: "object",
162
+ properties: {
163
+ name: {
164
+ type: "string",
165
+ description: "Name for the generation (max 100 characters)"
166
+ },
167
+ type: {
168
+ type: "string",
169
+ enum: [
170
+ "character",
171
+ "creature",
172
+ "environment",
173
+ "prop",
174
+ "weapon",
175
+ "vehicle",
176
+ "material",
177
+ "noun",
178
+ "person",
179
+ "place",
180
+ "thing"
181
+ ],
182
+ description: "Entity or legacy asset type"
183
+ },
184
+ prompt: {
185
+ type: "string",
186
+ description: "Text prompt describing what to generate"
187
+ },
188
+ image_url: {
189
+ type: "string",
190
+ description: "Optional URL to a reference image"
191
+ },
192
+ auto_rig: {
193
+ type: "boolean",
194
+ description: "Whether to rig for Mixamo when generating a person",
195
+ default: true
196
+ }
197
+ },
198
+ required: ["type"]
199
+ }
200
+ },
201
+ {
202
+ name: "get_model_status",
203
+ description: "Check generation status by asset_id.",
204
+ inputSchema: {
205
+ type: "object",
206
+ properties: {
207
+ asset_id: {
208
+ type: "string",
209
+ description: "The generation ID"
210
+ }
211
+ },
212
+ required: ["asset_id"]
213
+ }
214
+ },
215
+ {
216
+ name: "list_models",
217
+ description: "List generations for the current account.",
218
+ inputSchema: {
219
+ type: "object",
220
+ properties: {
221
+ limit: {
222
+ type: "number",
223
+ description: "Maximum rows to return (default: 20)",
224
+ default: 20
225
+ },
226
+ type: {
227
+ type: "string",
228
+ enum: [
229
+ "character",
230
+ "creature",
231
+ "environment",
232
+ "prop",
233
+ "weapon",
234
+ "vehicle",
235
+ "material",
236
+ "noun",
237
+ "person",
238
+ "place",
239
+ "thing"
240
+ ],
241
+ description: "Filter by entity/legacy type"
242
+ },
243
+ status: {
244
+ type: "string",
245
+ enum: [
246
+ "pending",
247
+ "preprocessing",
248
+ "generating",
249
+ "rigging",
250
+ "complete",
251
+ "failed"
252
+ ],
253
+ description: "Filter by generation status"
254
+ }
255
+ }
256
+ }
257
+ },
258
+ {
259
+ name: "get_model_download_url",
260
+ description: "Get the best available download URL for a completed generation.",
261
+ inputSchema: {
262
+ type: "object",
263
+ properties: {
264
+ asset_id: {
265
+ type: "string",
266
+ description: "The generation ID"
267
+ },
268
+ format: {
269
+ type: "string",
270
+ enum: ["model", "rigged"],
271
+ description: "Preferred format (default: best available)"
272
+ }
273
+ },
274
+ required: ["asset_id"]
275
+ }
276
+ },
277
+ {
278
+ name: "delete_model",
279
+ description: "Delete a generation.",
280
+ inputSchema: {
281
+ type: "object",
282
+ properties: {
283
+ asset_id: {
284
+ type: "string",
285
+ description: "The generation ID to delete"
286
+ }
287
+ },
288
+ required: ["asset_id"]
289
+ }
290
+ },
291
+ {
292
+ name: "capture_ui_screenshot",
293
+ description: "Capture a deterministic Playwright screenshot for UI/design iteration.",
294
+ inputSchema: {
295
+ type: "object",
296
+ properties: {
297
+ url: {
298
+ type: "string",
299
+ description: "HTTP/HTTPS URL to capture"
300
+ },
301
+ output_path: {
302
+ type: "string",
303
+ description: "Optional output file path. Relative paths are resolved in tmp/mcp-screenshots."
304
+ },
305
+ selector: {
306
+ type: "string",
307
+ description: "Optional CSS selector to capture only a specific element."
308
+ },
309
+ wait_for_selector: {
310
+ type: "string",
311
+ description: "Optional selector to wait for before capture (useful for async UI)."
312
+ },
313
+ wait_for_ms: {
314
+ type: "number",
315
+ description: "Extra delay before capture in milliseconds (default: 250).",
316
+ default: 250
317
+ },
318
+ timeout_ms: {
319
+ type: "number",
320
+ description: "Navigation and selector timeout in milliseconds (default: 30000).",
321
+ default: 3e4
322
+ },
323
+ full_page: {
324
+ type: "boolean",
325
+ description: "Capture full page when selector is not set (default: true).",
326
+ default: true
327
+ },
328
+ viewport_width: {
329
+ type: "number",
330
+ description: "Viewport width in px (default: 1440).",
331
+ default: 1440
332
+ },
333
+ viewport_height: {
334
+ type: "number",
335
+ description: "Viewport height in px (default: 900).",
336
+ default: 900
337
+ },
338
+ color_scheme: {
339
+ type: "string",
340
+ enum: ["light", "dark", "no-preference"],
341
+ description: "Preferred color scheme (default: dark).",
342
+ default: "dark"
343
+ },
344
+ format: {
345
+ type: "string",
346
+ enum: ["png", "jpeg"],
347
+ description: "Screenshot format (default inferred from output_path, else png)."
348
+ }
349
+ },
350
+ required: ["url"]
351
+ }
352
+ }
353
+ ];
354
+ function createServer(apiKey, baseUrl) {
355
+ const client = new WarlokClient({ apiKey, baseUrl });
356
+ const server = new Server(
357
+ {
358
+ name: "warlok-mcp",
359
+ version: "0.1.0"
360
+ },
361
+ {
362
+ capabilities: {
363
+ tools: {}
364
+ }
365
+ }
366
+ );
367
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
368
+ tools: TOOLS
369
+ }));
370
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
371
+ const { name, arguments: args } = request.params;
372
+ try {
373
+ switch (name) {
374
+ case "generate_3d_model": {
375
+ const generation = await client.generate({
376
+ name: stringOrUndefined(args, "name"),
377
+ type: requiredType(args),
378
+ prompt: stringOrUndefined(args, "prompt"),
379
+ imageUrl: stringOrUndefined(args, "image_url"),
380
+ options: {
381
+ autoRig: booleanOrUndefined(args, "auto_rig")
382
+ }
383
+ });
384
+ return {
385
+ content: [
386
+ {
387
+ type: "text",
388
+ text: JSON.stringify(
389
+ {
390
+ success: true,
391
+ asset_id: generation.id,
392
+ name: generation.name,
393
+ entity_type: generation.entityType,
394
+ status: generation.status,
395
+ message: `Generation started. Use get_model_status with asset_id "${generation.id}".`
396
+ },
397
+ null,
398
+ 2
399
+ )
400
+ }
401
+ ]
402
+ };
403
+ }
404
+ case "get_model_status": {
405
+ const generation = await client.getGeneration(requiredAssetId(args));
406
+ return {
407
+ content: [
408
+ {
409
+ type: "text",
410
+ text: JSON.stringify(formatGenerationStatus(generation), null, 2)
411
+ }
412
+ ]
413
+ };
414
+ }
415
+ case "list_models": {
416
+ const generations = await client.listGenerations({
417
+ limit: numberOrUndefined(args, "limit") || 20,
418
+ type: typeOrUndefined(args),
419
+ status: statusOrUndefined(args)
420
+ });
421
+ return {
422
+ content: [
423
+ {
424
+ type: "text",
425
+ text: JSON.stringify(
426
+ {
427
+ total: generations.length,
428
+ models: generations.map((generation) => ({
429
+ id: generation.id,
430
+ name: generation.name,
431
+ entity_type: generation.entityType,
432
+ status: generation.status,
433
+ created_at: generation.createdAt
434
+ }))
435
+ },
436
+ null,
437
+ 2
438
+ )
439
+ }
440
+ ]
441
+ };
442
+ }
443
+ case "get_model_download_url": {
444
+ const generation = await client.getGeneration(requiredAssetId(args));
445
+ if (generation.status !== "complete") {
446
+ return {
447
+ content: [
448
+ {
449
+ type: "text",
450
+ text: JSON.stringify({
451
+ success: false,
452
+ error: `Generation is not complete. Current status: ${generation.status}`
453
+ })
454
+ }
455
+ ]
456
+ };
457
+ }
458
+ const format = downloadFormatOrUndefined(args);
459
+ const url = format === "model" ? generation.outputs.modelUrl : format === "rigged" ? generation.outputs.riggedModelUrl : generation.outputs.riggedModelUrl || generation.outputs.modelUrl;
460
+ if (!url) {
461
+ return {
462
+ content: [
463
+ {
464
+ type: "text",
465
+ text: JSON.stringify({
466
+ success: false,
467
+ error: "No download URL available for this generation"
468
+ })
469
+ }
470
+ ]
471
+ };
472
+ }
473
+ return {
474
+ content: [
475
+ {
476
+ type: "text",
477
+ text: JSON.stringify(
478
+ {
479
+ success: true,
480
+ asset_id: generation.id,
481
+ name: generation.name,
482
+ download_url: url,
483
+ format: format || "best_available",
484
+ prompt: generation.input.prompt || null,
485
+ preview_png_url: generation.outputs.previewPngUrl || null,
486
+ nanobanana_png_url: generation.outputs.promptImageUrl || generation.provenance?.preprocessPromptImageUrl || null
487
+ },
488
+ null,
489
+ 2
490
+ )
491
+ }
492
+ ]
493
+ };
494
+ }
495
+ case "delete_model": {
496
+ const assetId = requiredAssetId(args);
497
+ await client.deleteGeneration(assetId);
498
+ return {
499
+ content: [
500
+ {
501
+ type: "text",
502
+ text: JSON.stringify({
503
+ success: true,
504
+ message: `Generation ${assetId} deleted successfully`
505
+ })
506
+ }
507
+ ]
508
+ };
509
+ }
510
+ case "capture_ui_screenshot": {
511
+ const screenshot = await captureUiScreenshot({
512
+ url: requiredUrl(args),
513
+ outputPath: stringOrUndefined(args, "output_path"),
514
+ selector: stringOrUndefined(args, "selector"),
515
+ waitForSelector: stringOrUndefined(args, "wait_for_selector"),
516
+ waitForMs: numberOrUndefined(args, "wait_for_ms"),
517
+ timeoutMs: numberOrUndefined(args, "timeout_ms"),
518
+ fullPage: booleanOrUndefined(args, "full_page"),
519
+ viewportWidth: numberOrUndefined(args, "viewport_width"),
520
+ viewportHeight: numberOrUndefined(args, "viewport_height"),
521
+ colorScheme: colorSchemeOrUndefined(args),
522
+ format: screenshotFormatOrUndefined(args)
523
+ });
524
+ return {
525
+ content: [
526
+ {
527
+ type: "text",
528
+ text: JSON.stringify(
529
+ {
530
+ success: true,
531
+ ...screenshot
532
+ },
533
+ null,
534
+ 2
535
+ )
536
+ }
537
+ ]
538
+ };
539
+ }
540
+ default:
541
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
542
+ }
543
+ } catch (error) {
544
+ if (error instanceof McpError) {
545
+ throw error;
546
+ }
547
+ const message = error instanceof Error ? error.message : "Unknown error";
548
+ return {
549
+ content: [
550
+ {
551
+ type: "text",
552
+ text: JSON.stringify({ success: false, error: message })
553
+ }
554
+ ],
555
+ isError: true
556
+ };
557
+ }
558
+ });
559
+ return server;
560
+ }
561
+ function requiredAssetId(args) {
562
+ const value = stringOrUndefined(args, "asset_id");
563
+ if (!value) {
564
+ throw new McpError(ErrorCode.InvalidParams, "asset_id is required");
565
+ }
566
+ return value;
567
+ }
568
+ function requiredType(args) {
569
+ const value = stringOrUndefined(args, "type");
570
+ if (!value) {
571
+ throw new McpError(ErrorCode.InvalidParams, "type is required");
572
+ }
573
+ return value;
574
+ }
575
+ function requiredUrl(args) {
576
+ const value = stringOrUndefined(args, "url");
577
+ if (!value) {
578
+ throw new McpError(ErrorCode.InvalidParams, "url is required");
579
+ }
580
+ return value;
581
+ }
582
+ function typeOrUndefined(args) {
583
+ const value = stringOrUndefined(args, "type");
584
+ return value;
585
+ }
586
+ function statusOrUndefined(args) {
587
+ const value = stringOrUndefined(args, "status");
588
+ return value;
589
+ }
590
+ function downloadFormatOrUndefined(args) {
591
+ const value = stringOrUndefined(args, "format");
592
+ if (value === "model" || value === "rigged") {
593
+ return value;
594
+ }
595
+ return void 0;
596
+ }
597
+ function stringOrUndefined(args, key) {
598
+ const value = args?.[key];
599
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
600
+ }
601
+ function booleanOrUndefined(args, key) {
602
+ const value = args?.[key];
603
+ return typeof value === "boolean" ? value : void 0;
604
+ }
605
+ function numberOrUndefined(args, key) {
606
+ const value = args?.[key];
607
+ return typeof value === "number" ? value : void 0;
608
+ }
609
+ function colorSchemeOrUndefined(args) {
610
+ const value = stringOrUndefined(args, "color_scheme");
611
+ if (value === "light" || value === "dark" || value === "no-preference") {
612
+ return value;
613
+ }
614
+ return void 0;
615
+ }
616
+ function screenshotFormatOrUndefined(args) {
617
+ const value = stringOrUndefined(args, "format");
618
+ if (value === "png" || value === "jpeg") {
619
+ return value;
620
+ }
621
+ return void 0;
622
+ }
623
+ function formatGenerationStatus(generation) {
624
+ const base = {
625
+ success: true,
626
+ asset_id: generation.id,
627
+ name: generation.name,
628
+ entity_type: generation.entityType,
629
+ status: generation.status,
630
+ progress: generation.progress,
631
+ prompt: generation.input.prompt || null,
632
+ preview_png_url: generation.outputs.previewPngUrl || null,
633
+ nanobanana_png_url: generation.outputs.promptImageUrl || generation.provenance?.preprocessPromptImageUrl || null,
634
+ created_at: generation.createdAt,
635
+ updated_at: generation.updatedAt
636
+ };
637
+ if (generation.status === "complete") {
638
+ return {
639
+ ...base,
640
+ model_url: generation.outputs.modelUrl,
641
+ rigged_model_url: generation.outputs.riggedModelUrl,
642
+ mixamo_ready: generation.outputs.mixamoReady
643
+ };
644
+ }
645
+ if (generation.status === "failed") {
646
+ return {
647
+ ...base,
648
+ error: generation.error
649
+ };
650
+ }
651
+ return {
652
+ ...base,
653
+ message: getStatusMessage(generation.status, generation.progress)
654
+ };
655
+ }
656
+ function getStatusMessage(status, progress) {
657
+ const messages = {
658
+ pending: "Queued for processing",
659
+ preprocessing: "Preparing specialized PNG",
660
+ generating: "Generating 3D mesh",
661
+ rigging: "Rigging for Mixamo"
662
+ };
663
+ return `${messages[status] || "Processing"} (${progress}% complete)`;
664
+ }
665
+ async function runServer(apiKey, baseUrl) {
666
+ const server = createServer(apiKey, baseUrl);
667
+ const transport = new StdioServerTransport();
668
+ await server.connect(transport);
669
+ process.on("SIGINT", async () => {
670
+ await server.close();
671
+ process.exit(0);
672
+ });
673
+ }
674
+
675
+ export {
676
+ createServer,
677
+ runServer
678
+ };