norc-mcp 0.2.2 → 0.3.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/dist/server.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
2
3
  import { createServer } from 'node:http';
4
+ import { pathToFileURL } from 'node:url';
3
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
7
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
8
  import { VaultClient } from './vault-client.js';
6
9
  import { registerJobTools } from './tools/jobs.js';
10
+ import { registerGitopsTools } from './tools/gitops.js';
7
11
  import { registerMachineTools } from './tools/machines.js';
8
12
  import { registerRunTools } from './tools/runs.js';
9
13
  const VAULT_BASE_URL = process.env.NORC_VAULT_URL ?? 'https://vault.norc.app';
@@ -15,16 +19,29 @@ export function extractApiKey(headers) {
15
19
  return raw.startsWith('norc_sk_') ? raw : null;
16
20
  }
17
21
  function buildServer(apiKey) {
18
- const server = new McpServer({ name: 'norc-mcp', version: '0.1.0' });
22
+ const server = new McpServer({ name: 'norc-mcp', version: '0.3.0' });
19
23
  const client = new VaultClient(apiKey, VAULT_BASE_URL);
20
24
  registerJobTools(server, client);
21
25
  registerMachineTools(server, client);
22
26
  registerRunTools(server, client);
27
+ registerGitopsTools(server, client);
23
28
  return server;
24
29
  }
25
- // Only run the HTTP listener when executed directly, so tests can import
26
- // extractApiKey/buildServer without binding a port.
27
- if (import.meta.url === `file://${process.argv[1]}`) {
30
+ // stdio mode (a local MCP client spawns us, key comes from the environment) when
31
+ // NORC_API_KEY is set or --stdio is passed; otherwise the HTTP listener (Docker, self-hosting).
32
+ export function wantsStdio(env, argv) {
33
+ return argv.includes('--stdio') || Boolean(env.NORC_API_KEY);
34
+ }
35
+ async function runStdio() {
36
+ const apiKey = process.env.NORC_API_KEY ?? '';
37
+ if (!apiKey.startsWith('norc_sk_')) {
38
+ // stdout carries the protocol, so diagnostics go to stderr
39
+ console.error('norc-mcp: set NORC_API_KEY to a norc API key (norc_sk_...)');
40
+ process.exit(1);
41
+ }
42
+ await buildServer(apiKey).connect(new StdioServerTransport());
43
+ }
44
+ function runHttp() {
28
45
  const port = Number(process.env.PORT ?? 3000);
29
46
  createServer(async (req, res) => {
30
47
  if (req.url !== '/mcp' || req.method !== 'POST') {
@@ -49,3 +66,11 @@ if (import.meta.url === `file://${process.argv[1]}`) {
49
66
  console.log(`norc-mcp listening on :${port}`);
50
67
  });
51
68
  }
69
+ // Only start when executed directly, so tests can import extractApiKey/buildServer without
70
+ // binding a port. realpath: an npm bin is a symlink, so argv[1] differs from import.meta.url.
71
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
72
+ if (wantsStdio(process.env, process.argv))
73
+ await runStdio();
74
+ else
75
+ runHttp();
76
+ }
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ // JSON key, not "a/b": names may contain "/" and would collide.
3
+ const key = (machine, name) => JSON.stringify([machine, name]);
4
+ export function computeDiff(desired, machines, jobs) {
5
+ const machineName = new Map(machines.map((m) => [m.id, m.name]));
6
+ const machineId = new Map(machines.map((m) => [m.name, m.id]));
7
+ const live = new Map(jobs.map((j) => [key(machineName.get(j.machine_id) ?? '', j.name), j]));
8
+ const want = new Set(desired.map((d) => key(d.machine, d.name)));
9
+ const errors = [];
10
+ const creates = [];
11
+ const updates = [];
12
+ for (const d of desired) {
13
+ const k = `${d.machine}/${d.name}`;
14
+ const mid = machineId.get(d.machine);
15
+ if (!mid) {
16
+ errors.push(`${k}: unknown machine "${d.machine}"`);
17
+ continue;
18
+ }
19
+ const existing = live.get(key(d.machine, d.name));
20
+ if (!existing)
21
+ creates.push({ key: k, machineId: mid, name: d.name, schedule: d.schedule, command: d.command, enabled: d.enabled });
22
+ else if (existing.schedule !== d.schedule || existing.command !== d.command || existing.enabled !== d.enabled) {
23
+ updates.push({ key: k, id: existing.id, patch: { schedule: d.schedule, command: d.command, enabled: d.enabled } });
24
+ }
25
+ }
26
+ const deletes = jobs
27
+ .filter((j) => !want.has(key(machineName.get(j.machine_id) ?? '', j.name)))
28
+ .map((j) => ({ key: `${machineName.get(j.machine_id) ?? j.machine_id}/${j.name}`, id: j.id }));
29
+ return { creates, updates, deletes, errors };
30
+ }
31
+ export function registerGitopsTools(server, client) {
32
+ server.registerTool('sync_jobs', {
33
+ description: "Make the account's jobs match a declared set (declarative, like `norc gitops`). Jobs not listed are DELETED. " +
34
+ 'Defaults to a dry run that only returns the diff; set apply=true to execute it. Always show the user the dry-run diff, ' +
35
+ 'especially the deletes, and get their confirmation before applying. Requires the gitops sync feature on the account.',
36
+ inputSchema: {
37
+ jobs: z
38
+ .array(z.object({
39
+ machine: z.string().describe('Machine name (not id)'),
40
+ name: z.string(),
41
+ schedule: z.string().describe('Cron expression, e.g. "0 * * * *"'),
42
+ command: z.string(),
43
+ enabled: z.boolean().optional().describe('Defaults to true'),
44
+ }))
45
+ .describe('The complete desired job set across all machines'),
46
+ apply: z.boolean().optional().describe('Execute the diff. Default false (dry run).'),
47
+ },
48
+ }, async ({ jobs, apply }) => {
49
+ const desired = jobs.map((j) => ({ ...j, enabled: j.enabled ?? true }));
50
+ const [machines, live] = await Promise.all([client.get('/api/v1/machines'), client.get('/api/v1/jobs')]);
51
+ const { errors, ...diff } = computeDiff(desired, machines, live);
52
+ if (errors.length > 0) {
53
+ return { isError: true, content: [{ type: 'text', text: `Not applied. ${errors.join('; ')}` }] };
54
+ }
55
+ if (!apply) {
56
+ return { content: [{ type: 'text', text: JSON.stringify({ applied: false, diff }, null, 2) }] };
57
+ }
58
+ const result = await client.post('/api/v1/jobs/sync', diff);
59
+ return { content: [{ type: 'text', text: JSON.stringify({ applied: true, ...result }, null, 2) }] };
60
+ });
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "norc-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for connecting AI platforms to your norc account — manage cron jobs, machines, and run history.",
5
5
  "type": "module",
6
6
  "license": "MIT",