norc-mcp 0.1.0 → 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/README.md +42 -2
- package/dist/server.js +29 -4
- package/dist/tools/gitops.js +61 -0
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -5,15 +5,55 @@ MCP server for connecting AI platforms (Claude, ChatGPT, etc.) to your [norc](ht
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
7
7
|
1. Generate a norc API key: vault → Settings → API Keys → Generate key.
|
|
8
|
-
2.
|
|
8
|
+
2. Install it for your client (below). Hosted option: point a client that supports remote servers at `https://mcp.norc.app/mcp` with your key as the Bearer token (exact field depends on the client).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
### Claude Desktop
|
|
13
|
+
|
|
14
|
+
1. Install [Node.js](https://nodejs.org) 20 or newer (Claude Desktop runs the server with `npx`).
|
|
15
|
+
2. Open Claude Desktop → Settings → Developer → Edit Config. This opens `claude_desktop_config.json`:
|
|
16
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
17
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json` (Microsoft Store install: `%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.json`)
|
|
18
|
+
3. Add norc under `mcpServers` (keep any servers already there) and use your own key (vault → Settings → API Keys):
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"norc": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "norc-mcp"],
|
|
25
|
+
"env": { "NORC_API_KEY": "norc_sk_..." }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
On Windows, `npx` is a `.cmd` script and may fail to launch directly; use `cmd` instead:
|
|
31
|
+
```json
|
|
32
|
+
"command": "cmd",
|
|
33
|
+
"args": ["/c", "npx", "-y", "norc-mcp"]
|
|
34
|
+
```
|
|
35
|
+
4. Quit Claude Desktop completely and reopen it. Closing the window is not enough. norc's tools then appear in the tools menu.
|
|
36
|
+
|
|
37
|
+
### Claude Code
|
|
38
|
+
|
|
39
|
+
Install the [norc plugin](https://github.com/norcapp/norc-claude-plugin), which adds slash commands and starts this server for you, or add it directly:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
claude mcp add norc --env NORC_API_KEY=norc_sk_... -- npx -y norc-mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Other local MCP clients (Cursor, etc.)
|
|
46
|
+
|
|
47
|
+
Use the same `command` / `args` / `env` block as the Claude Desktop config above. Without `NORC_API_KEY` (or `--stdio`) the server starts in HTTP mode instead, and a missing or malformed key in stdio mode exits with an error.
|
|
9
48
|
|
|
10
49
|
## Tools
|
|
11
50
|
|
|
12
51
|
- `list_jobs`, `create_job`, `update_job`, `delete_job`
|
|
13
52
|
- `list_machines`, `rename_machine`, `delete_machine`
|
|
14
53
|
- `list_runs`
|
|
54
|
+
- `sync_jobs` — declarative sync of your full job set (like `norc gitops`). Dry run by default; jobs not listed are deleted when `apply` is true. Needs the gitops sync feature enabled on your account.
|
|
15
55
|
|
|
16
|
-
## Self-hosting
|
|
56
|
+
## Self-hosting (HTTP)
|
|
17
57
|
|
|
18
58
|
This server is a thin, stateless client — it holds no secrets and no database access of its own. It only forwards your API key to `vault.norc.app` (or `$NORC_VAULT_URL`). Run your own instance if you'd like:
|
|
19
59
|
|
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.
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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.
|
|
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",
|
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
"test": "vitest run"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
35
|
-
"zod": "^4.
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
35
|
+
"zod": "^4.6.7"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"
|
|
39
|
-
"tsx": "^4.
|
|
40
|
-
"
|
|
41
|
-
"
|
|
38
|
+
"@types/node": "^22.20.5",
|
|
39
|
+
"tsx": "^4.23.15",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vite": "^8.3.5",
|
|
42
|
+
"vitest": "^5.0.5"
|
|
42
43
|
}
|
|
43
44
|
}
|