Add basic build/deploy pipeline
This commit is contained in:
27
README.md
27
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Meta MCP is a ContextVM server that accepts **requirements** for new MCP tools and returns a job id. It is intended to be used by other AIs that only have access to a single "meta" tool. The meta MCP then builds, deploys, and announces a new MCP server via Nostr (ContextVM).
|
Meta MCP is a ContextVM server that accepts **requirements** for new MCP tools and returns a job id. It is intended to be used by other AIs that only have access to a single "meta" tool. The meta MCP then builds, deploys, and announces a new MCP server via Nostr (ContextVM).
|
||||||
|
|
||||||
This repo contains the **server-side** MCP for the meta tool. It currently implements the request + job tracking surface; the build pipeline is stubbed and will be wired to the ContextVM/CVMI deployment flow next.
|
This repo contains the **server-side** MCP for the meta tool. It implements the request + job tracking surface and a simple build/deploy pipeline (scaffold → build → announce via CVMI).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,12 +86,31 @@ You can set `CVMI_SERVE_*` env vars to configure relays, encryption, and keys.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
The pipeline is currently minimal and runs locally:
|
||||||
|
|
||||||
|
1) Scaffold a new MCP server in `outputDir`
|
||||||
|
2) `npm install` + `npm run build`
|
||||||
|
3) Run `npx cvmi serve -- node dist/server.js`
|
||||||
|
4) Parse and store the announced pubkey
|
||||||
|
|
||||||
|
Configuration is read from `/root/.meta-mcp/config.json` (or `META_MCP_CONFIG`).
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"outputDir": "/root/.openclaw/workspace/generated",
|
||||||
|
"cvmi": { "seedPath": "/root/.cvmi/seed.json" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- Wire this server to a build/deploy pipeline
|
|
||||||
- Add persistent storage for job state
|
- Add persistent storage for job state
|
||||||
- Auto-create repos + scaffolds for new MCPs
|
- Repo creation + git push for generated MCPs
|
||||||
- Auto-start new MCP servers and announce via CVMI
|
- Systemd services per generated MCP
|
||||||
|
- Security controls / rate limits
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
44
src/config.ts
Normal file
44
src/config.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export type MetaMcpConfig = {
|
||||||
|
gitea?: {
|
||||||
|
baseUrl: string; // e.g. https://git.schlaustronics.com
|
||||||
|
owner: string; // username or org
|
||||||
|
token: string; // personal access token
|
||||||
|
defaultVisibility?: 'private' | 'public';
|
||||||
|
};
|
||||||
|
outputDir?: string; // where new MCP repos are generated
|
||||||
|
cvmi?: {
|
||||||
|
seedPath?: string; // path to seed.json with nsec + relays
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS: MetaMcpConfig = {
|
||||||
|
outputDir: '/root/.openclaw/workspace/generated',
|
||||||
|
cvmi: {
|
||||||
|
seedPath: '/root/.cvmi/seed.json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadConfig(): MetaMcpConfig {
|
||||||
|
const configPath = process.env.META_MCP_CONFIG || '/root/.meta-mcp/config.json';
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
return DEFAULTS;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as MetaMcpConfig;
|
||||||
|
return {
|
||||||
|
...DEFAULTS,
|
||||||
|
...parsed,
|
||||||
|
cvmi: { ...DEFAULTS.cvmi, ...parsed.cvmi },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureDir(dir: string) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePath(...parts: string[]) {
|
||||||
|
return path.resolve(...parts);
|
||||||
|
}
|
||||||
143
src/pipeline.ts
Normal file
143
src/pipeline.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { loadConfig, ensureDir } from './config.js';
|
||||||
|
import type { MetaRequest, MetaResponse } from './types.js';
|
||||||
|
import { updateJob } from './storage.js';
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], cwd?: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
child.stdout.on('data', (d) => (out += d.toString()));
|
||||||
|
child.stderr.on('data', (d) => (err += d.toString()));
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) return resolve(out.trim());
|
||||||
|
reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${err}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)+/g, '')
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaffoldServer(repoDir: string, request: MetaRequest) {
|
||||||
|
const name = request.requirements[0]?.name ?? 'generated-tool';
|
||||||
|
const toolDesc = request.requirements[0]?.description ?? 'Generated tool';
|
||||||
|
const pkg = {
|
||||||
|
name: name.replace(/[^a-z0-9\-]/gi, '-').toLowerCase(),
|
||||||
|
version: '0.1.0',
|
||||||
|
type: 'module',
|
||||||
|
scripts: {
|
||||||
|
build: 'tsc -p tsconfig.json',
|
||||||
|
start: 'node dist/server.js',
|
||||||
|
dev: 'ts-node --esm src/server.ts',
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
'@modelcontextprotocol/sdk': '^1.27.1',
|
||||||
|
zod: '^3.25.0',
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
'ts-node': '^10.9.2',
|
||||||
|
typescript: '^5.6.3',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tsconfig = {
|
||||||
|
compilerOptions: {
|
||||||
|
target: 'ES2022',
|
||||||
|
module: 'NodeNext',
|
||||||
|
moduleResolution: 'NodeNext',
|
||||||
|
outDir: 'dist',
|
||||||
|
rootDir: 'src',
|
||||||
|
strict: true,
|
||||||
|
esModuleInterop: true,
|
||||||
|
skipLibCheck: true,
|
||||||
|
forceConsistentCasingInFileNames: true,
|
||||||
|
},
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolName = name;
|
||||||
|
const inputSchema = JSON.stringify(request.requirements[0]?.inputSchema ?? {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverCode = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { z } from 'zod';\n\nconst server = new McpServer({ name: '${name}', version: '0.1.0' });\n\nconst InputSchema = z.object(${inputSchema});\n\nserver.registerTool('${toolName}', {\n description: '${toolDesc.replace(/'/g, "\\'")}',\n inputSchema: InputSchema\n}, async (args) => {\n // TODO: implement tool logic\n return {\n content: [{ type: 'text', text: JSON.stringify({ ok: true, args }, null, 2) }]\n };\n});\n\nasync function main() {\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n\nmain().catch((err) => {\n console.error('Server failed', err);\n process.exit(1);\n});\n`;
|
||||||
|
|
||||||
|
const readme = `# ${name}\n\nAuto-generated MCP server from Meta MCP.\n\n## Tool\n- **${toolName}**: ${toolDesc}\n\n## Dev\n\n\
|
||||||
|
\
|
||||||
|
\
|
||||||
|
\
|
||||||
|
\`\`\`bash\nnpm install\nnpm run dev\n\`\`\`\n`;
|
||||||
|
|
||||||
|
ensureDir(path.join(repoDir, 'src'));
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify(pkg, null, 2));
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2));
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'README.md'), readme);
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'src/server.ts'), serverCode);
|
||||||
|
fs.writeFileSync(path.join(repoDir, '.gitignore'), 'node_modules\n/dist\n.env\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPipeline(jobId: string, request: MetaRequest): Promise<MetaResponse> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const outputDir = config.outputDir ?? '/root/.openclaw/workspace/generated';
|
||||||
|
ensureDir(outputDir);
|
||||||
|
|
||||||
|
const baseName = request.requirements[0]?.name ?? 'generated-tool';
|
||||||
|
const repoName = `${slugify(baseName)}-${jobId.slice(0, 8)}`;
|
||||||
|
const repoDir = path.join(outputDir, repoName);
|
||||||
|
|
||||||
|
updateJob(jobId, { status: 'building', message: `Scaffolding ${repoName}` });
|
||||||
|
scaffoldServer(repoDir, request);
|
||||||
|
|
||||||
|
updateJob(jobId, { status: 'building', message: `Installing dependencies in ${repoName}` });
|
||||||
|
await run('npm', ['install'], repoDir);
|
||||||
|
|
||||||
|
updateJob(jobId, { status: 'building', message: `Building ${repoName}` });
|
||||||
|
await run('npm', ['run', 'build'], repoDir);
|
||||||
|
|
||||||
|
const seedPath = config.cvmi?.seedPath ?? '/root/.cvmi/seed.json';
|
||||||
|
const seedRaw = fs.readFileSync(seedPath, 'utf8');
|
||||||
|
const seed = JSON.parse(seedRaw) as { nsec: string; relays: string[] };
|
||||||
|
|
||||||
|
updateJob(jobId, { status: 'running', message: `Starting CVMI for ${repoName}` });
|
||||||
|
const env = { ...process.env, CVMI_SERVE_PRIVATE_KEY: seed.nsec };
|
||||||
|
const cvmiArgs = ['-y', 'cvmi', 'serve', '--', 'node', 'dist/server.js'];
|
||||||
|
const child = spawn('npx', cvmiArgs, { cwd: repoDir, env, stdio: 'pipe' });
|
||||||
|
|
||||||
|
let pubkey: string | undefined;
|
||||||
|
child.stdout.on('data', (d) => {
|
||||||
|
const text = d.toString();
|
||||||
|
if (!pubkey) {
|
||||||
|
const match = text.match(/Public key:\s*([0-9a-f]{64})/i);
|
||||||
|
if (match) {
|
||||||
|
pubkey = match[1];
|
||||||
|
updateJob(jobId, {
|
||||||
|
status: 'running',
|
||||||
|
message: `Server running for ${repoName}`,
|
||||||
|
serverPubkey: pubkey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (d) => {
|
||||||
|
const text = d.toString();
|
||||||
|
updateJob(jobId, { logs: [text] });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'running',
|
||||||
|
message: `Started ${repoName}. Awaiting pubkey...`,
|
||||||
|
jobId,
|
||||||
|
serverPubkey: pubkey,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createJob, getJob, listJobs, updateJob } from './storage.js';
|
import { createJob, getJob, listJobs, updateJob } from './storage.js';
|
||||||
import type { MetaRequest, MetaResponse } from './types.js';
|
import type { MetaRequest, MetaResponse } from './types.js';
|
||||||
|
import { runPipeline } from './pipeline.js';
|
||||||
|
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{
|
{
|
||||||
name: 'meta-mcp',
|
name: 'meta-mcp',
|
||||||
version: '0.1.0',
|
version: '0.2.0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -48,15 +49,22 @@ server.registerTool(
|
|||||||
const parsed = MetaRequestSchema.parse(args);
|
const parsed = MetaRequestSchema.parse(args);
|
||||||
const job = createJob(parsed as MetaRequest);
|
const job = createJob(parsed as MetaRequest);
|
||||||
|
|
||||||
// TODO: Hook into build/deploy pipeline
|
|
||||||
updateJob(job.id, {
|
updateJob(job.id, {
|
||||||
status: 'building',
|
status: 'building',
|
||||||
message: 'Accepted. Building MCP server (pipeline stub).',
|
message: 'Accepted. Starting build pipeline.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run pipeline asynchronously
|
||||||
|
runPipeline(job.id, parsed as MetaRequest).catch((err) => {
|
||||||
|
updateJob(job.id, {
|
||||||
|
status: 'failed',
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: MetaResponse = {
|
const response: MetaResponse = {
|
||||||
status: 'building',
|
status: 'building',
|
||||||
message: 'Accepted. Building MCP server (pipeline stub).',
|
message: 'Accepted. Starting build pipeline.',
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user