@cmpose/mcp 0.3.0 → 0.4.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
 
@@ -78,6 +79,14 @@ saving, deep links, and in-app review and update check.
78
79
  - **Your names everywhere:** package (application identifier), company name, app name, min SDK
79
80
  (25 to 36) and iOS deployment target (15.0 to 26.2).
80
81
 
82
+ > "Create a backend called NotesBackend, service name notes-api, package com.acme.notes, in
83
+ > ~/Projects, with sharing"
84
+
85
+ Every backend is a Kotlin (JVM 21) Ktor server with PostgreSQL (Exposed, Flyway), Firebase ID
86
+ tokens verified on the server (no Admin SDK), file uploads, account deletion, one sample feature
87
+ to copy, a security checklist, a local runner (`./gradlew runLocal`) and the parts to deploy it
88
+ as one Docker container behind nginx. `sharing` adds spaces, roles and invitations.
89
+
81
90
  ## Tools
82
91
 
83
92
  - `cmp_describe`: the defaults, the options and the rules (name formats, limits, which option
@@ -97,6 +106,15 @@ saving, deep links, and in-app review and update check.
97
106
  capitalized), `templateType`, `render`, `flags`, `features` (module names: 1 to 20 lowercase
98
107
  letters or digits, starting with a letter), `minSdk` (25 to 36), `iosVersion` (15.0 to 26.2,
99
108
  written as `major.minor`), `ids`, `templateRef`. It never overwrites a non-empty folder.
109
+ - `backend_describe`: the backend generator's options, `appName` rules, the project anatomy and
110
+ the conventions to follow when adding code (protected routes, the 404 rule, where features
111
+ go, the security checklist, the local runner and the deploy parts). Agents call it first.
112
+ - `backend_generate`: creates the backend. Required: `projectName` (letters only, up to 30),
113
+ `appName` (the service name: 2 to 31 lowercase letters, digits or `-`, starting with a letter;
114
+ not `api`, `health`, `admin` or `static`), `packageName` (e.g. `com.acme.notes`) and an
115
+ absolute `outputDir`. Optional: `sharing` (default `false`), `templateRef`. It never
116
+ overwrites a non-empty folder. `CMPOSE_BACKEND_TEMPLATE_DIR` (the absolute path of a backend
117
+ template checkout) generates from that checkout instead of the pinned tag.
100
118
 
101
119
  ## How it works
102
120
 
@@ -105,7 +123,8 @@ Generation runs entirely on your machine. The first call downloads the pinned te
105
123
  `~/.cache/cmpose` (override with `CMPOSE_CACHE_DIR`), and later calls reuse it. Games come the
106
124
  same way from the pinned game template
107
125
  ([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`
126
+ never runs during generation. Backends come from the pinned backend template
127
+ (`whinstudio/cmpose-backend-template`). Each release pins one tag of each template, so `@cmpose/mcp@x.y.z`
109
128
  always generates the same project.
110
129
 
111
130
  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.4.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.4.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 };
package/src/server.js CHANGED
@@ -8,6 +8,7 @@ const pkg = require('../package.json');
8
8
  const { describe } = require('./describe');
9
9
  const { runGenerate } = require('./generate');
10
10
  const { runGameGenerate } = 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. ' +
@@ -18,6 +19,10 @@ const GAME_GENERATE_DESCRIPTION = 'Generate a Unity 6 (Android + iOS) mobile gam
18
19
  'showcase has every flag on; flags and features apply to blank only. ' +
19
20
  'Writes <outputDir>/<projectName>; never overwrites an existing non-empty folder.';
20
21
 
22
+ const BACKEND_GENERATE_DESCRIPTION = 'Generate a standalone Kotlin/Ktor backend (Docker-ready) on this machine. ' +
23
+ 'Call backend_describe first. ' +
24
+ 'Writes <outputDir>/<projectName>; never overwrites an existing non-empty folder.';
25
+
21
26
  // MCP progress notifications for a tool call that sent a progress token
22
27
  function progressReporter(extra, total) {
23
28
  const progressToken = extra && extra._meta && extra._meta.progressToken;
@@ -32,7 +37,8 @@ function progressReporter(extra, total) {
32
37
  };
33
38
  }
34
39
 
35
- function createServer({ cacheDir, remoteUrl } = {}) {
40
+ // backendTemplateDir: a local backend template checkout used instead of the template cache (CMPOSE_BACKEND_TEMPLATE_DIR)
41
+ function createServer({ cacheDir, remoteUrl, backendTemplateDir = process.env.CMPOSE_BACKEND_TEMPLATE_DIR || null } = {}) {
36
42
  const server = new McpServer({ name: 'cmpose', version: pkg.version });
37
43
 
38
44
  server.registerTool('cmp_describe', {
@@ -96,6 +102,29 @@ function createServer({ cacheDir, remoteUrl } = {}) {
96
102
  cacheDir, remoteUrl, onProgress: progressReporter(extra, 2), generatorVersion: `mcp@${pkg.version}`
97
103
  }));
98
104
 
105
+ server.registerTool('backend_describe', {
106
+ description: 'Describe the Kotlin/Ktor backend generator: options, appName rules, the project anatomy, the pinned backend template ' +
107
+ 'and the conventions to follow when writing code in a generated backend.',
108
+ inputSchema: {}
109
+ }, async () => {
110
+ const facts = core.backend.describeBackend();
111
+ return { content: [{ type: 'text', text: JSON.stringify(facts, null, 2) }], structuredContent: facts };
112
+ });
113
+
114
+ server.registerTool('backend_generate', {
115
+ description: BACKEND_GENERATE_DESCRIPTION,
116
+ inputSchema: {
117
+ projectName: z.string().describe('Letters only, no spaces (e.g. NotesBackend)'),
118
+ appName: z.string().describe('Service name: 2-31 lowercase letters, digits or -, starting with a letter (e.g. notes-api)'),
119
+ packageName: z.string().describe('Reverse-domain Kotlin package (e.g. com.example.notes)'),
120
+ outputDir: z.string().describe('Absolute path of the folder to create the project in'),
121
+ sharing: z.boolean().optional().describe('Spaces shared between users with roles and invitations (default false)'),
122
+ templateRef: z.string().optional().describe('Override the pinned backend template tag')
123
+ }
124
+ }, async (args, extra) => runBackendGenerate(args, {
125
+ cacheDir, remoteUrl, templateDir: backendTemplateDir, onProgress: progressReporter(extra, 2), generatorVersion: `mcp@${pkg.version}`
126
+ }));
127
+
99
128
  return server;
100
129
  }
101
130