@cmpose/mcp 0.3.0 → 0.5.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 CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  Ask your AI agent for a **Compose Multiplatform** app and get a real, buildable Android + iOS
4
4
  project on disk, generated locally from a tested template. It also generates **Unity** mobile
5
- games (Android + iOS). This is a local stdio MCP server with four tools: `cmp_describe`,
6
- `cmp_generate`, `game_describe` and `game_generate`. It runs on your machine, with no account,
7
- no API key and no upload.
5
+ games (Android + iOS) and standalone **Kotlin/Ktor backends**. This is a local stdio MCP
6
+ server with six tools: `cmp_describe`, `cmp_generate`, `game_describe`, `game_generate`,
7
+ `backend_describe` and `backend_generate`. It runs on your machine, with no account, no API key
8
+ and no upload.
8
9
 
9
10
  > "Create a blank Compose Multiplatform app called Habits, package com.acme.habits, in
10
11
  > ~/Projects, with Room, light/dark theming and AdMob ads."
@@ -17,7 +18,7 @@ Requires Node.js 20+ and git.
17
18
 
18
19
  **Claude Code**
19
20
 
20
- claude mcp add cmpose -- npx -y @cmpose/mcp
21
+ claude mcp add -s user cmpose -- npx -y @cmpose/mcp
21
22
 
22
23
  **Claude Desktop, Cursor, Windsurf and other MCP clients** (`mcpServers` config):
23
24
 
@@ -75,9 +76,24 @@ saving, deep links, and in-app review and update check.
75
76
  - **IDs:** `ids` takes your real AdMob (`admob`), RevenueCat (`revenueCat`) and Play Games +
76
77
  Game Center (`gameServices`) IDs, each group with its flag; `game_describe` lists the fields.
77
78
  A group you leave out keeps the test IDs / placeholders and is reported as a warning.
79
+ - **Look (`theme`):** one of the template's style families (`bright`, `neon`, `pastel`, `bold`,
80
+ `nature`, `clean`; palette, shapes, motion and a font pair), listed by `game_describe` under
81
+ `themes` with what each suits. `theme: { family, primary, accent, fontPair }`, every field
82
+ optional: `primary` and `accent` (`#RRGGBB`) recolour the family with their lightness shifted
83
+ until text and buttons keep their contrast; a colour that still cannot pass keeps the
84
+ family's and is reported as a warning. Without `theme` the project gets `clean`, exactly as
85
+ the template ships it.
78
86
  - **Your names everywhere:** package (application identifier), company name, app name, min SDK
79
87
  (25 to 36) and iOS deployment target (15.0 to 26.2).
80
88
 
89
+ > "Create a backend called NotesBackend, service name notes-api, package com.acme.notes, in
90
+ > ~/Projects, with sharing"
91
+
92
+ Every backend is a Kotlin (JVM 21) Ktor server with PostgreSQL (Exposed, Flyway), Firebase ID
93
+ tokens verified on the server (no Admin SDK), file uploads, account deletion, one sample feature
94
+ to copy, a security checklist, a local runner (`./gradlew runLocal`) and the parts to deploy it
95
+ as one Docker container behind nginx. `sharing` adds spaces, roles and invitations.
96
+
81
97
  ## Tools
82
98
 
83
99
  - `cmp_describe`: the defaults, the options and the rules (name formats, limits, which option
@@ -89,14 +105,27 @@ saving, deep links, and in-app review and update check.
89
105
  It returns the project path, the template version it used, warnings (e.g. test ad IDs to
90
106
  replace) and next steps.
91
107
  - `game_describe`: the Unity game generator's engine and editor version, template types,
92
- renders, flags and their dependencies, ID groups, defaults and limits. Agents call it first.
108
+ renders, flags and their dependencies, ID groups, defaults, limits and the pinned template's
109
+ theme families (`themes`: `default` and each family's `id`, `title`, `suits` and `fontPairs`;
110
+ the first call downloads the template). Agents call it first.
93
111
  - `game_generate`: creates the game. Required: `projectName` (1 to 30 letters or digits,
94
112
  starting with a letter), `appName` (up to 30 characters), `packageName` (e.g.
95
113
  `com.acme.skyhopper`, at least 3 segments, up to 50 characters) and an absolute `outputDir`.
96
114
  Optional: `companyName` (like `projectName`; by default the package's second segment,
97
115
  capitalized), `templateType`, `render`, `flags`, `features` (module names: 1 to 20 lowercase
98
116
  letters or digits, starting with a letter), `minSdk` (25 to 36), `iosVersion` (15.0 to 26.2,
99
- written as `major.minor`), `ids`, `templateRef`. It never overwrites a non-empty folder.
117
+ written as `major.minor`), `ids`, `theme`, `templateRef`. It never overwrites a non-empty
118
+ folder. The result names the applied theme (`theme`) and lists colour fallbacks among the
119
+ warnings.
120
+ - `backend_describe`: the backend generator's options, `appName` rules, the project anatomy and
121
+ the conventions to follow when adding code (protected routes, the 404 rule, where features
122
+ go, the security checklist, the local runner and the deploy parts). Agents call it first.
123
+ - `backend_generate`: creates the backend. Required: `projectName` (letters only, up to 30),
124
+ `appName` (the service name: 2 to 31 lowercase letters, digits or `-`, starting with a letter;
125
+ not `api`, `health`, `admin` or `static`), `packageName` (e.g. `com.acme.notes`) and an
126
+ absolute `outputDir`. Optional: `sharing` (default `false`), `templateRef`. It never
127
+ overwrites a non-empty folder. `CMPOSE_BACKEND_TEMPLATE_DIR` (the absolute path of a backend
128
+ template checkout) generates from that checkout instead of the pinned tag.
100
129
 
101
130
  ## How it works
102
131
 
@@ -105,7 +134,8 @@ Generation runs entirely on your machine. The first call downloads the pinned te
105
134
  `~/.cache/cmpose` (override with `CMPOSE_CACHE_DIR`), and later calls reuse it. Games come the
106
135
  same way from the pinned game template
107
136
  ([whinstudio/cmpose-game-template](https://github.com/whinstudio/cmpose-game-template)); Unity
108
- never runs during generation. Each release pins one tag of each template, so `@cmpose/mcp@x.y.z`
137
+ never runs during generation. Backends come from the pinned backend template
138
+ (`whinstudio/cmpose-backend-template`). Each release pins one tag of each template, so `@cmpose/mcp@x.y.z`
109
139
  always generates the same project.
110
140
 
111
141
  Source and issues: [github.com/whinstudio/cmpose](https://github.com/whinstudio/cmpose).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmpose/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.Keremturker/cmpose",
5
5
  "description": "MCP server that generates buildable Compose Multiplatform (Android + iOS) projects and Unity game projects locally from any AI agent",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "test": "node --test test/*.test.js"
44
44
  },
45
45
  "dependencies": {
46
- "@cmpose/core": "0.3.0",
46
+ "@cmpose/core": "0.5.0",
47
47
  "@modelcontextprotocol/sdk": "1.32.1",
48
48
  "fs-extra": "^11.3.2",
49
49
  "zod": "3.25.76"
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const { execFile } = require('child_process');
5
+ const { promisify } = require('util');
6
+ const fs = require('fs-extra');
7
+ const core = require('@cmpose/core');
8
+ const { removeEmptyDir, targetProblem } = require('./generate');
9
+
10
+ const fail = (text) => ({ isError: true, content: [{ type: 'text', text }] });
11
+
12
+ const NEXT_STEPS_BACKEND = [
13
+ 'Create .local-server/config.properties with firebaseProjectId=<your Firebase project id>, then run ./gradlew runLocal (port 18001).',
14
+ 'Add features under feature/<name>/ by copying feature/sample, and go through SECURITY-CHECKLIST.md.',
15
+ 'Read deploy/README.md for the server contract, the first deployment and updates.'
16
+ ];
17
+
18
+ // A local checkout's working files that a released template never holds
19
+ const LOCAL_SKIP = new Set(['.git', '.claude', '.idea', '.gradle', '.kotlin', '.local-server', 'build', '.DS_Store']);
20
+ const isPending = (ref) => /-PENDING$/.test(ref);
21
+ // Secret files a checkout outside git may hold, skipped at every level (deploy/.env.example is the template's own)
22
+ const isSecretName = (name) => name !== '.env.example' && (name === '.env' || name.endsWith('.env') || name.startsWith('.env.')
23
+ || /\.(p8|pem|key)$/.test(name) || /^service-account.*\.json$/.test(name)
24
+ || name === 'google-services.json' || name === 'GoogleService-Info.plist');
25
+ const LOCAL_DIRTY_WARNING = 'The local template checkout has uncommitted changes; the project holds them.';
26
+
27
+ const git = (dir, args) => promisify(execFile)('git', ['-C', dir, ...args], { maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' });
28
+
29
+ // A git checkout's tracked files (relative paths), or null when dir is not in a git work tree
30
+ async function trackedFiles(dir) {
31
+ try {
32
+ await git(dir, ['rev-parse', '--is-inside-work-tree']);
33
+ } catch (err) {
34
+ if (err.code === 'ENOENT' && (await fs.pathExists(path.join(dir, '.git')))) {
35
+ throw new TypeError('git is needed to copy the local template checkout (CMPOSE_BACKEND_TEMPLATE_DIR).');
36
+ }
37
+ return null;
38
+ }
39
+ const { stdout } = await git(dir, ['ls-files', '-z']);
40
+ return stdout.split('\0').filter(Boolean);
41
+ }
42
+
43
+ // Copies a local checkout into work: only git's tracked files, or (outside git) all but the working and secret files.
44
+ // Returns the warnings the result carries.
45
+ async function copyLocalTemplate(dir, work) {
46
+ const tracked = await trackedFiles(dir);
47
+ if (!tracked) {
48
+ const filter = (src) => !isSecretName(path.basename(src)) && (path.dirname(src) !== dir || !LOCAL_SKIP.has(path.basename(src)));
49
+ await fs.copy(dir, work, { filter });
50
+ return [];
51
+ }
52
+ let copied = 0;
53
+ for (const rel of tracked) {
54
+ if (LOCAL_SKIP.has(rel.split('/')[0])) continue;
55
+ const src = path.join(dir, rel);
56
+ const stat = await fs.lstat(src).catch(() => null);
57
+ // a tracked file deleted in the work tree, or a submodule, is not copied
58
+ if (!stat || stat.isDirectory()) continue;
59
+ await fs.copy(src, path.join(work, rel)); // keeps the mode, so the executable bits too
60
+ copied += 1;
61
+ }
62
+ if (!copied) throw new TypeError('The local template checkout (CMPOSE_BACKEND_TEMPLATE_DIR) has no tracked files.');
63
+ const { stdout } = await git(dir, ['status', '--porcelain', '--untracked-files=no']);
64
+ return stdout.trim() ? [LOCAL_DIRTY_WARNING] : [];
65
+ }
66
+
67
+ // The template source: a local checkout (templateDir / CMPOSE_BACKEND_TEMPLATE_DIR) or the template cache
68
+ async function resolveTemplate({ templateRef, templateDir, cacheDir, remoteUrl, cacheEnsure, onProgress }) {
69
+ if (templateDir) {
70
+ const stat = path.isAbsolute(templateDir) ? await fs.stat(templateDir).catch(() => null) : null;
71
+ if (!stat || !stat.isDirectory()) throw new TypeError('CMPOSE_BACKEND_TEMPLATE_DIR must be the absolute path of a backend template checkout.');
72
+ onProgress('Preparing the local template');
73
+ return { dir: templateDir, ref: templateRef || 'local', commit: null, local: true };
74
+ }
75
+ const template = { repo: core.pins.backend.repo, ref: templateRef || core.pins.backend.ref, cacheDir };
76
+ if (isPending(template.ref)) {
77
+ throw new TypeError('The backend template is not released yet; pass templateRef or set CMPOSE_BACKEND_TEMPLATE_DIR.');
78
+ }
79
+ const cached = await core.templateCache.isCached(template);
80
+ onProgress(cached ? 'Preparing the template' : 'Downloading the template (first run only)');
81
+ return cacheEnsure({ ...template, remoteUrl });
82
+ }
83
+
84
+ // Validate → template cache → generate in a hidden sibling → rename into place (the runGameGenerate flow).
85
+ // Never overwrites anything and never leaves half a project behind.
86
+ async function runBackendGenerate(args, {
87
+ cacheDir, remoteUrl, templateDir = null, cacheEnsure = core.templateCache.ensure,
88
+ onProgress = () => { }, generatorVersion = null, now
89
+ } = {}) {
90
+ const checked = core.backend.validateBackend(args);
91
+ if (!checked.ok) return fail(['Invalid input:', ...checked.errors.map((e) => `- ${e.field}: ${e.message}`)].join('\n'));
92
+ const value = checked.value;
93
+ const target = path.join(value.outputDir, value.projectName);
94
+ const problem = await targetProblem(target);
95
+ if (problem) return fail(problem);
96
+
97
+ let cache;
98
+ try {
99
+ cache = await resolveTemplate({ templateRef: value.templateRef, templateDir, cacheDir, remoteUrl, cacheEnsure, onProgress });
100
+ } catch (err) {
101
+ if (err instanceof core.TemplateFetchError || err instanceof TypeError) return fail(err.message);
102
+ throw err;
103
+ }
104
+
105
+ await fs.ensureDir(value.outputDir);
106
+ const work = path.join(value.outputDir, `.cmpose-${crypto.randomBytes(6).toString('hex')}`);
107
+ try {
108
+ onProgress('Generating the project');
109
+ let localWarnings = [];
110
+ if (cache.local) localWarnings = await copyLocalTemplate(cache.dir, work);
111
+ else await fs.copy(cache.dir, work);
112
+ // Called through core.backend at call time (never destructured): the tests replace it to fail mid-generation
113
+ const { record } = await core.backend.generateBackend(value, {
114
+ projectDir: work, templateRef: cache.ref, templateCommit: cache.commit, generatorVersion, ...(now ? { now } : {})
115
+ });
116
+ const late = await targetProblem(target);
117
+ if (late) throw new Error(late);
118
+ await removeEmptyDir(target); // never recursive: a concurrent run's project stays intact
119
+ await fs.rename(work, target);
120
+ const warnings = cache.local
121
+ ? ['Generated from a local template checkout (CMPOSE_BACKEND_TEMPLATE_DIR), not a released tag.', ...localWarnings] : [];
122
+ const result = { projectDir: target, sharing: value.sharing, template: record.template, record, warnings, nextSteps: NEXT_STEPS_BACKEND };
123
+ const text = [
124
+ `Generated ${value.projectName} at ${target}`,
125
+ `Template: ${record.template.repo}@${record.template.ref}${record.template.commit ? ` (${String(record.template.commit).slice(0, 7)})` : ''}`,
126
+ `Sharing: ${value.sharing ? 'on' : 'off'}`,
127
+ ...warnings.map((w) => `Warning: ${w}`),
128
+ 'Next steps:', ...NEXT_STEPS_BACKEND.map((s) => `- ${s}`)
129
+ ].join('\n');
130
+ return { content: [{ type: 'text', text }], structuredContent: result };
131
+ } catch (err) {
132
+ await fs.remove(work).catch(() => { });
133
+ return fail(`Generation failed: ${err.message}`);
134
+ }
135
+ }
136
+
137
+ module.exports = { runBackendGenerate, NEXT_STEPS_BACKEND };
@@ -59,12 +59,14 @@ async function runGameGenerate(args, { cacheDir, remoteUrl, onProgress = () => {
59
59
  render: summary.render,
60
60
  flags: summary.flags,
61
61
  features: summary.features,
62
+ theme: summary.theme,
62
63
  warnings: summary.warnings,
63
64
  nextSteps: NEXT_STEPS_GAME
64
65
  };
65
66
  const text = [
66
67
  `Generated ${value.projectName} at ${target}`,
67
68
  `Template: ${source.repo}@${source.ref} (${String(source.commit).slice(0, 7)})`,
69
+ ...(result.theme ? [`Theme: ${result.theme.family} (${result.theme.fontPair})`] : []),
68
70
  ...result.warnings.map((w) => `Warning: ${w}`),
69
71
  'Next steps:', ...NEXT_STEPS_GAME.map((s) => `- ${s}`)
70
72
  ].join('\n');
@@ -75,4 +77,21 @@ async function runGameGenerate(args, { cacheDir, remoteUrl, onProgress = () => {
75
77
  }
76
78
  }
77
79
 
78
- module.exports = { runGameGenerate, NEXT_STEPS_GAME };
80
+ // game_describe with the pinned template's theme families (fetched into the cache on first use).
81
+ // A template that cannot be fetched or read still describes the generator: no families, and why.
82
+ async function runGameDescribe({ cacheDir, remoteUrl } = {}) {
83
+ let themes = null;
84
+ let unavailable = null;
85
+ try {
86
+ const cache = await core.templateCache.ensure({ ...core.pins.game, cacheDir, remoteUrl });
87
+ themes = await core.game.readThemes(cache.dir);
88
+ } catch (err) {
89
+ if (!(err instanceof core.TemplateFetchError || err instanceof TypeError || err instanceof core.game.ThemesFileError)) throw err;
90
+ unavailable = `Theme families unavailable: ${err.message}`;
91
+ }
92
+ const facts = core.game.describeGame({ themes });
93
+ if (unavailable) facts.themes.unavailable = unavailable;
94
+ return { content: [{ type: 'text', text: JSON.stringify(facts, null, 2) }], structuredContent: facts };
95
+ }
96
+
97
+ module.exports = { runGameGenerate, runGameDescribe, NEXT_STEPS_GAME };
package/src/server.js CHANGED
@@ -7,7 +7,8 @@ const core = require('@cmpose/core');
7
7
  const pkg = require('../package.json');
8
8
  const { describe } = require('./describe');
9
9
  const { runGenerate } = require('./generate');
10
- const { runGameGenerate } = require('./gameGenerate');
10
+ const { runGameGenerate, runGameDescribe } = require('./gameGenerate');
11
+ const { runBackendGenerate } = require('./backendGenerate');
11
12
 
12
13
  const GENERATE_DESCRIPTION = 'Generate a Compose Multiplatform (Android + iOS) project on this machine. ' +
13
14
  'Call cmp_describe first for the options, defaults and validation rules. ' +
@@ -16,6 +17,15 @@ const GENERATE_DESCRIPTION = 'Generate a Compose Multiplatform (Android + iOS) p
16
17
  const GAME_GENERATE_DESCRIPTION = 'Generate a Unity 6 (Android + iOS) mobile game project on this machine. ' +
17
18
  'Call game_describe first for the flags, defaults and validation rules. ' +
18
19
  'showcase has every flag on; flags and features apply to blank only. ' +
20
+ 'theme picks one of the style families game_describe lists under themes (palette, shapes, motion, fonts; ' +
21
+ 'match the family whose suits fits the game) and optionally a fontPair of that family. primary and accent ' +
22
+ '(#RRGGBB) recolour the family: their lightness is shifted until text and buttons keep their contrast, and a ' +
23
+ 'colour that still cannot pass falls back to the family default with a warning. Without theme the template ' +
24
+ 'default family (clean) is used. ' +
25
+ 'Writes <outputDir>/<projectName>; never overwrites an existing non-empty folder.';
26
+
27
+ const BACKEND_GENERATE_DESCRIPTION = 'Generate a standalone Kotlin/Ktor backend (Docker-ready) on this machine. ' +
28
+ 'Call backend_describe first. ' +
19
29
  'Writes <outputDir>/<projectName>; never overwrites an existing non-empty folder.';
20
30
 
21
31
  // MCP progress notifications for a tool call that sent a progress token
@@ -32,7 +42,8 @@ function progressReporter(extra, total) {
32
42
  };
33
43
  }
34
44
 
35
- function createServer({ cacheDir, remoteUrl } = {}) {
45
+ // backendTemplateDir: a local backend template checkout used instead of the template cache (CMPOSE_BACKEND_TEMPLATE_DIR)
46
+ function createServer({ cacheDir, remoteUrl, backendTemplateDir = process.env.CMPOSE_BACKEND_TEMPLATE_DIR || null } = {}) {
36
47
  const server = new McpServer({ name: 'cmpose', version: pkg.version });
37
48
 
38
49
  server.registerTool('cmp_describe', {
@@ -64,12 +75,9 @@ function createServer({ cacheDir, remoteUrl } = {}) {
64
75
  }));
65
76
 
66
77
  server.registerTool('game_describe', {
67
- description: 'Describe the Unity game generator: engine and editor version, template types, renders, flags and their dependencies, ID groups, defaults, validation limits and the pinned game template.',
78
+ description: 'Describe the Unity game generator: engine and editor version, template types, renders, flags and their dependencies, ID groups, defaults, validation limits, the pinned game template and its theme families (themes: default, and each family\'s id, title, suits and fontPairs).',
68
79
  inputSchema: {}
69
- }, async () => {
70
- const facts = core.game.describeGame();
71
- return { content: [{ type: 'text', text: JSON.stringify(facts, null, 2) }], structuredContent: facts };
72
- });
80
+ }, async () => runGameDescribe({ cacheDir, remoteUrl }));
73
81
 
74
82
  server.registerTool('game_generate', {
75
83
  description: GAME_GENERATE_DESCRIPTION,
@@ -90,12 +98,43 @@ function createServer({ cacheDir, remoteUrl } = {}) {
90
98
  ids: z.record(z.string(), z.record(z.string(), z.string())).optional()
91
99
  .describe('Real IDs by group (admob, revenueCat, gameServices; fields in game_describe), each needing its flag. ' +
92
100
  'A group left out keeps the test IDs / placeholders, reported as a warning'),
101
+ // passthrough: unknown fields reach validateGame, which refuses them by name
102
+ theme: z.object({
103
+ family: z.string().optional().describe('A family id from game_describe themes.families (default: themes.default)'),
104
+ primary: z.string().optional().describe('#RRGGBB primary colour; lightness is adjusted to keep contrast'),
105
+ accent: z.string().optional().describe('#RRGGBB accent colour; lightness is adjusted to keep contrast'),
106
+ fontPair: z.string().optional().describe("One of the family's fontPairs (default: its first)")
107
+ }).passthrough().optional()
108
+ .describe('Style family and optional colour shifts; without it the template default family is used'),
93
109
  templateRef: z.string().optional().describe('Override the pinned game template tag')
94
110
  }
95
111
  }, async (args, extra) => runGameGenerate(args, {
96
112
  cacheDir, remoteUrl, onProgress: progressReporter(extra, 2), generatorVersion: `mcp@${pkg.version}`
97
113
  }));
98
114
 
115
+ server.registerTool('backend_describe', {
116
+ description: 'Describe the Kotlin/Ktor backend generator: options, appName rules, the project anatomy, the pinned backend template ' +
117
+ 'and the conventions to follow when writing code in a generated backend.',
118
+ inputSchema: {}
119
+ }, async () => {
120
+ const facts = core.backend.describeBackend();
121
+ return { content: [{ type: 'text', text: JSON.stringify(facts, null, 2) }], structuredContent: facts };
122
+ });
123
+
124
+ server.registerTool('backend_generate', {
125
+ description: BACKEND_GENERATE_DESCRIPTION,
126
+ inputSchema: {
127
+ projectName: z.string().describe('Letters only, no spaces (e.g. NotesBackend)'),
128
+ appName: z.string().describe('Service name: 2-31 lowercase letters, digits or -, starting with a letter (e.g. notes-api)'),
129
+ packageName: z.string().describe('Reverse-domain Kotlin package (e.g. com.example.notes)'),
130
+ outputDir: z.string().describe('Absolute path of the folder to create the project in'),
131
+ sharing: z.boolean().optional().describe('Spaces shared between users with roles and invitations (default false)'),
132
+ templateRef: z.string().optional().describe('Override the pinned backend template tag')
133
+ }
134
+ }, async (args, extra) => runBackendGenerate(args, {
135
+ cacheDir, remoteUrl, templateDir: backendTemplateDir, onProgress: progressReporter(extra, 2), generatorVersion: `mcp@${pkg.version}`
136
+ }));
137
+
99
138
  return server;
100
139
  }
101
140