@cmpose/mcp 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cmpose contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @cmpose/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local stdio MCP server with two tools, `cmp_describe` and `cmp_generate`, that generates Compose Multiplatform projects on your own machine through `@cmpose/core`. Add it to Claude Code with `claude mcp add cmpose -- npx -y @cmpose/mcp`. See the README at the root of the cmpose repository for details.
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@cmpose/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Local MCP server that generates Compose Multiplatform projects (cmpose)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Keremturker/cmpose.git",
9
+ "directory": "packages/mcp"
10
+ },
11
+ "homepage": "https://github.com/Keremturker/cmpose/tree/main/packages/mcp#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Keremturker/cmpose/issues"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "compose-multiplatform",
19
+ "kotlin-multiplatform",
20
+ "kmp",
21
+ "generator",
22
+ "scaffold"
23
+ ],
24
+ "bin": {
25
+ "cmpose-mcp": "src/server.js"
26
+ },
27
+ "main": "src/server.js",
28
+ "files": [
29
+ "src"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test test/*.test.js"
36
+ },
37
+ "dependencies": {
38
+ "@cmpose/core": "0.1.1",
39
+ "@modelcontextprotocol/sdk": "1.32.1",
40
+ "fs-extra": "^11.3.2",
41
+ "zod": "3.25.76"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+ const core = require('@cmpose/core');
3
+ const pkg = require('../package.json');
4
+
5
+ const DEFAULTS = Object.freeze({
6
+ templateType: 'showcase',
7
+ features: [],
8
+ minSdk: String(core.limits.minSdkMin),
9
+ iosVersion: core.limits.iosVersionMin,
10
+ ci: false
11
+ });
12
+
13
+ function describe() {
14
+ const contract = core.contractFacts();
15
+ return {
16
+ contractVersion: core.hashContract(contract),
17
+ coreVersion: core.version,
18
+ mcpVersion: pkg.version,
19
+ template: { ...core.pins.cmp },
20
+ // Outside `contract`: contractVersion hashes the contract only
21
+ defaults: { ...DEFAULTS, featuresConfig: { ...core.featuresConfig.DEFAULTS } },
22
+ contract
23
+ };
24
+ }
25
+
26
+ module.exports = { describe, DEFAULTS };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const fs = require('fs-extra');
5
+ const core = require('@cmpose/core');
6
+ const { DEFAULTS } = require('./describe');
7
+
8
+ const fail = (text) => ({ isError: true, content: [{ type: 'text', text }] });
9
+
10
+ const NEXT_STEPS = [
11
+ 'Open the project folder in Android Studio (it writes local.properties with your Android SDK path).',
12
+ 'Run the Android app from Android Studio; for iOS open iosApp/iosApp.xcodeproj in Xcode.',
13
+ 'Read README.md for the modules, requirements and release checklist.'
14
+ ];
15
+
16
+ async function targetProblem(target) {
17
+ const stat = await fs.stat(target).catch(() => null);
18
+ if (!stat) return null;
19
+ if (!stat.isDirectory()) return `${target} already exists and is a file`;
20
+ if ((await fs.readdir(target)).length > 0) return `${target} already exists and is not empty`;
21
+ return null;
22
+ }
23
+
24
+ // Non-recursive: fails (ENOTEMPTY) if anything appeared at the target since it was checked.
25
+ async function removeEmptyDir(dir) {
26
+ await fs.promises.rmdir(dir).catch((err) => { if (err.code !== 'ENOENT') throw err; });
27
+ }
28
+
29
+ // Validate → template cache → generate in a hidden sibling → rename into place.
30
+ // Never overwrites anything and never leaves half a project behind.
31
+ // A typo in a flag would otherwise just leave the feature out and still report success
32
+ function unknownFeaturesConfigKeys(featuresConfig) {
33
+ if (!featuresConfig || typeof featuresConfig !== 'object' || Array.isArray(featuresConfig)) return [];
34
+ return Object.keys(featuresConfig).filter((k) => !core.SCHEMA.featuresConfigKeys.includes(k));
35
+ }
36
+
37
+ // Agents often send numbers; the core takes the hosted generator's strings
38
+ function coerceVersions(options) {
39
+ const out = { ...options };
40
+ if (typeof out.minSdk === 'number') out.minSdk = String(out.minSdk);
41
+ if (typeof out.iosVersion === 'number') {
42
+ out.iosVersion = Number.isInteger(out.iosVersion) ? `${out.iosVersion}.0` : String(out.iosVersion);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ async function runGenerate(args, { cacheDir, remoteUrl, onProgress = () => { }, generatorVersion = null } = {}) {
48
+ const { outputDir, templateRef, ...rest } = args || {};
49
+ const options = coerceVersions(rest);
50
+ if (typeof outputDir !== 'string' || !path.isAbsolute(outputDir)) {
51
+ return fail('outputDir must be an absolute path (for example /path/to/projects).');
52
+ }
53
+ const unknown = unknownFeaturesConfigKeys(options.featuresConfig);
54
+ if (unknown.length > 0) {
55
+ return fail(`Unknown featuresConfig key(s): ${unknown.join(', ')}. ` +
56
+ `Valid keys: ${core.SCHEMA.featuresConfigKeys.join(', ')}.`);
57
+ }
58
+ const checked = core.validate({ ...DEFAULTS, ...options });
59
+ if (!checked.ok) return fail(checked.error);
60
+ const value = checked.value;
61
+ const target = path.join(outputDir, value.projectName);
62
+ const problem = await targetProblem(target);
63
+ if (problem) return fail(problem);
64
+
65
+ const template = { repo: core.pins.cmp.repo, ref: templateRef || core.pins.cmp.ref, cacheDir };
66
+ let cache;
67
+ try {
68
+ const cached = await core.templateCache.isCached(template);
69
+ onProgress(cached ? 'Preparing the template' : 'Downloading the template (first run only)');
70
+ cache = await core.templateCache.ensure({ ...template, remoteUrl });
71
+ } catch (err) {
72
+ if (err instanceof core.TemplateFetchError || err instanceof TypeError) return fail(err.message);
73
+ throw err;
74
+ }
75
+ const versions = await core.readTemplateVersions(cache.dir);
76
+ const sdkError = core.minSdkAboveTarget(value.minSdk, versions.targetSdk);
77
+ if (sdkError) return fail(sdkError);
78
+
79
+ await fs.ensureDir(outputDir);
80
+ const work = path.join(outputDir, `.cmpose-${crypto.randomBytes(6).toString('hex')}`);
81
+ try {
82
+ onProgress('Generating the project');
83
+ await fs.copy(cache.dir, work);
84
+ const summary = await core.generateCmpProject(value, { projectDir: work, generatorVersion });
85
+ const late = await targetProblem(target);
86
+ if (late) throw new Error(late);
87
+ await removeEmptyDir(target); // never recursive: a concurrent run's project stays intact
88
+ await fs.rename(work, target);
89
+ const warnings = [...summary.warnings];
90
+ if (value.templateType === 'showcase' && options.featuresConfig !== undefined) {
91
+ warnings.push('featuresConfig is ignored for showcase (it applies to blank only); the showcase template has its own fixed feature set');
92
+ }
93
+ const result = {
94
+ projectDir: target,
95
+ template: { repo: core.pins.cmp.repo, ref: cache.ref, commit: cache.commit },
96
+ templateType: summary.templateType,
97
+ features: summary.features,
98
+ featuresConfig: value.templateType === 'blank' ? value.featuresConfig : null,
99
+ warnings,
100
+ nextSteps: NEXT_STEPS
101
+ };
102
+ const text = [
103
+ `Generated ${value.projectName} at ${target}`,
104
+ `Template: ${result.template.repo}@${result.template.ref} (${String(cache.commit).slice(0, 7)})`,
105
+ ...warnings.map((w) => `Warning: ${w}`),
106
+ 'Next steps:', ...NEXT_STEPS.map((s) => `- ${s}`)
107
+ ].join('\n');
108
+ return { content: [{ type: 'text', text }], structuredContent: result };
109
+ } catch (err) {
110
+ await fs.remove(work).catch(() => { });
111
+ return fail(`Generation failed: ${err.message}`);
112
+ }
113
+ }
114
+
115
+ module.exports = { runGenerate, removeEmptyDir, NEXT_STEPS };
package/src/server.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
4
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
5
+ const { z } = require('zod');
6
+ const core = require('@cmpose/core');
7
+ const pkg = require('../package.json');
8
+ const { describe } = require('./describe');
9
+ const { runGenerate } = require('./generate');
10
+
11
+ const GENERATE_DESCRIPTION = 'Generate a Compose Multiplatform (Android + iOS) project on this machine. ' +
12
+ 'Call cmp_describe first for the options, defaults and validation rules. ' +
13
+ 'Writes <outputDir>/<projectName>; never overwrites an existing non-empty folder.';
14
+
15
+ function createServer({ cacheDir, remoteUrl } = {}) {
16
+ const server = new McpServer({ name: 'cmpose', version: pkg.version });
17
+
18
+ server.registerTool('cmp_describe', {
19
+ description: 'Describe the cmpose generator: template types, featuresConfig flags and defaults, dependency rules, validation limits and the pinned template.',
20
+ inputSchema: {}
21
+ }, async () => {
22
+ const facts = describe();
23
+ return { content: [{ type: 'text', text: JSON.stringify(facts, null, 2) }], structuredContent: facts };
24
+ });
25
+
26
+ server.registerTool('cmp_generate', {
27
+ description: GENERATE_DESCRIPTION,
28
+ inputSchema: {
29
+ projectName: z.string().describe('Letters only, no spaces (e.g. MyApp)'),
30
+ appName: z.string().describe('Display name (e.g. My App)'),
31
+ packageName: z.string().describe('Reverse-domain package (e.g. com.example.myapp)'),
32
+ outputDir: z.string().describe('Absolute path of the folder to create the project in'),
33
+ templateType: z.enum(['showcase', 'blank']).optional(),
34
+ features: z.array(z.string()).optional().describe('Extra feature modules (lower case names)'),
35
+ featuresConfig: z.record(z.string(), z.any()).optional()
36
+ .describe('blank only: flags from cmp_describe (unknown keys are refused; ignored for showcase)'),
37
+ minSdk: z.union([z.string(), z.number()]).optional().describe('Android min SDK, e.g. "24" or 24'),
38
+ iosVersion: z.union([z.string(), z.number()]).optional().describe('iOS deployment target, e.g. "15.0"'),
39
+ ci: z.boolean().optional(),
40
+ templateRef: z.string().optional().describe('Override the pinned template tag')
41
+ }
42
+ }, async (args, extra) => {
43
+ const progressToken = extra && extra._meta && extra._meta.progressToken;
44
+ let step = 0;
45
+ const onProgress = (message) => {
46
+ if (progressToken === undefined) return;
47
+ step += 1;
48
+ extra.sendNotification({
49
+ method: 'notifications/progress',
50
+ params: { progressToken, progress: step, total: 2, message }
51
+ }).catch(() => { });
52
+ };
53
+ return runGenerate(args, { cacheDir, remoteUrl, onProgress, generatorVersion: `mcp@${pkg.version}` });
54
+ });
55
+
56
+ return server;
57
+ }
58
+
59
+ async function main() {
60
+ // stdout carries JSON-RPC: every log line goes to stderr
61
+ const toStderr = (...args) => console.error(...args);
62
+ core.setLogger({ info: toStderr, warn: toStderr, error: toStderr });
63
+ await createServer().connect(new StdioServerTransport());
64
+ }
65
+
66
+ if (require.main === module) {
67
+ main().catch((err) => {
68
+ console.error(err);
69
+ process.exit(1);
70
+ });
71
+ }
72
+
73
+ module.exports = { createServer };