@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 +24 -5
- package/package.json +2 -2
- package/src/backendGenerate.js +137 -0
- package/src/server.js +30 -1
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)
|
|
6
|
-
`cmp_generate`, `game_describe
|
|
7
|
-
|
|
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.
|
|
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
|
+
"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.
|
|
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
|
-
|
|
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
|
|