norc-mcp 0.0.0-stage → 0.2.2
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 +21 -0
- package/README.md +81 -2
- package/dist/server.js +51 -0
- package/dist/tools/jobs.js +48 -0
- package/dist/tools/machines.js +15 -0
- package/dist/tools/runs.js +22 -0
- package/dist/vault-client.js +47 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Asher Peruscini
|
|
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,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# norc-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for connecting AI platforms (Claude, ChatGPT, etc.) to your [norc](https://norc.app) account — manage cron jobs, machines, and run history from a chat.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. Generate a norc API key: vault → Settings → API Keys → Generate key.
|
|
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.
|
|
48
|
+
|
|
49
|
+
## Tools
|
|
50
|
+
|
|
51
|
+
- `list_jobs`, `create_job`, `update_job`, `delete_job`
|
|
52
|
+
- `list_machines`, `rename_machine`, `delete_machine`
|
|
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.
|
|
55
|
+
|
|
56
|
+
## Self-hosting (HTTP)
|
|
57
|
+
|
|
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:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pnpm install
|
|
62
|
+
pnpm build
|
|
63
|
+
PORT=3000 node dist/server.js
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Note: self-hosting this server does not mean self-hosting norc itself — it still talks to the hosted norc API.
|
|
67
|
+
|
|
68
|
+
## Run via Docker
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
docker run -p 3000:3000 norcapp/norc-mcp
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Set `NORC_VAULT_URL` if you're pointing at a non-default vault instance:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
docker run -p 3000:3000 -e NORC_VAULT_URL=https://vault.norc.app norcapp/norc-mcp
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import { VaultClient } from './vault-client.js';
|
|
6
|
+
import { registerJobTools } from './tools/jobs.js';
|
|
7
|
+
import { registerMachineTools } from './tools/machines.js';
|
|
8
|
+
import { registerRunTools } from './tools/runs.js';
|
|
9
|
+
const VAULT_BASE_URL = process.env.NORC_VAULT_URL ?? 'https://vault.norc.app';
|
|
10
|
+
export function extractApiKey(headers) {
|
|
11
|
+
const auth = headers.get('authorization') ?? '';
|
|
12
|
+
if (!auth.startsWith('Bearer '))
|
|
13
|
+
return null;
|
|
14
|
+
const raw = auth.slice('Bearer '.length).trim();
|
|
15
|
+
return raw.startsWith('norc_sk_') ? raw : null;
|
|
16
|
+
}
|
|
17
|
+
function buildServer(apiKey) {
|
|
18
|
+
const server = new McpServer({ name: 'norc-mcp', version: '0.1.0' });
|
|
19
|
+
const client = new VaultClient(apiKey, VAULT_BASE_URL);
|
|
20
|
+
registerJobTools(server, client);
|
|
21
|
+
registerMachineTools(server, client);
|
|
22
|
+
registerRunTools(server, client);
|
|
23
|
+
return server;
|
|
24
|
+
}
|
|
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]}`) {
|
|
28
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
29
|
+
createServer(async (req, res) => {
|
|
30
|
+
if (req.url !== '/mcp' || req.method !== 'POST') {
|
|
31
|
+
res.writeHead(404).end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const headers = new Headers(req.headers);
|
|
35
|
+
const apiKey = extractApiKey(headers);
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'missing or malformed norc API key' }));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const server = buildServer(apiKey);
|
|
41
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
42
|
+
res.on('close', () => {
|
|
43
|
+
transport.close();
|
|
44
|
+
server.close();
|
|
45
|
+
});
|
|
46
|
+
await server.connect(transport);
|
|
47
|
+
await transport.handleRequest(req, res);
|
|
48
|
+
}).listen(port, () => {
|
|
49
|
+
console.log(`norc-mcp listening on :${port}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export function registerJobTools(server, client) {
|
|
3
|
+
server.registerTool('list_jobs', {
|
|
4
|
+
description: "List the user's cron jobs, optionally filtered by machine.",
|
|
5
|
+
inputSchema: { machine_id: z.string().optional() },
|
|
6
|
+
}, async ({ machine_id }) => {
|
|
7
|
+
const params = new URLSearchParams();
|
|
8
|
+
if (machine_id)
|
|
9
|
+
params.set('machine_id', machine_id);
|
|
10
|
+
const qs = params.toString();
|
|
11
|
+
const path = `/api/v1/jobs${qs ? `?${qs}` : ''}`;
|
|
12
|
+
const jobs = await client.get(path);
|
|
13
|
+
return { content: [{ type: 'text', text: JSON.stringify(jobs, null, 2) }] };
|
|
14
|
+
});
|
|
15
|
+
server.registerTool('create_job', {
|
|
16
|
+
description: 'Create a new cron job on a machine.',
|
|
17
|
+
inputSchema: {
|
|
18
|
+
machine_id: z.string(),
|
|
19
|
+
name: z.string(),
|
|
20
|
+
schedule: z.string().describe('Cron expression, e.g. "0 * * * *"'),
|
|
21
|
+
command: z.string(),
|
|
22
|
+
enabled: z.boolean().optional(),
|
|
23
|
+
},
|
|
24
|
+
}, async (input) => {
|
|
25
|
+
const job = await client.post('/api/v1/jobs', input);
|
|
26
|
+
return { content: [{ type: 'text', text: JSON.stringify(job, null, 2) }] };
|
|
27
|
+
});
|
|
28
|
+
server.registerTool('update_job', {
|
|
29
|
+
description: 'Update an existing job (name, schedule, command, or enabled state).',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
id: z.string(),
|
|
32
|
+
name: z.string().optional(),
|
|
33
|
+
schedule: z.string().optional(),
|
|
34
|
+
command: z.string().optional(),
|
|
35
|
+
enabled: z.boolean().optional(),
|
|
36
|
+
},
|
|
37
|
+
}, async ({ id, ...patch }) => {
|
|
38
|
+
const job = await client.patch(`/api/v1/jobs/${encodeURIComponent(id)}`, patch);
|
|
39
|
+
return { content: [{ type: 'text', text: JSON.stringify(job, null, 2) }] };
|
|
40
|
+
});
|
|
41
|
+
server.registerTool('delete_job', {
|
|
42
|
+
description: 'Delete a job.',
|
|
43
|
+
inputSchema: { id: z.string() },
|
|
44
|
+
}, async ({ id }) => {
|
|
45
|
+
await client.delete(`/api/v1/jobs/${encodeURIComponent(id)}`);
|
|
46
|
+
return { content: [{ type: 'text', text: `Deleted job ${id}.` }] };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export function registerMachineTools(server, client) {
|
|
3
|
+
server.registerTool('list_machines', { description: "List the user's paired machines.", inputSchema: {} }, async () => {
|
|
4
|
+
const machines = await client.get('/api/v1/machines');
|
|
5
|
+
return { content: [{ type: 'text', text: JSON.stringify(machines, null, 2) }] };
|
|
6
|
+
});
|
|
7
|
+
server.registerTool('rename_machine', { description: 'Rename a machine.', inputSchema: { id: z.string(), name: z.string() } }, async ({ id, name }) => {
|
|
8
|
+
const machine = await client.patch(`/api/v1/machines/${encodeURIComponent(id)}`, { name });
|
|
9
|
+
return { content: [{ type: 'text', text: JSON.stringify(machine, null, 2) }] };
|
|
10
|
+
});
|
|
11
|
+
server.registerTool('delete_machine', { description: 'Unpair (delete) a machine.', inputSchema: { id: z.string() } }, async ({ id }) => {
|
|
12
|
+
await client.delete(`/api/v1/machines/${encodeURIComponent(id)}`);
|
|
13
|
+
return { content: [{ type: 'text', text: `Unpaired machine ${id}.` }] };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export function registerRunTools(server, client) {
|
|
3
|
+
server.registerTool('list_runs', {
|
|
4
|
+
description: 'List job run history, optionally filtered by job, machine, or status. Paginated via cursor.',
|
|
5
|
+
inputSchema: {
|
|
6
|
+
job_id: z.string().optional(),
|
|
7
|
+
machine_id: z.string().optional(),
|
|
8
|
+
status: z.enum(['success', 'warning', 'failed', 'running', 'disabled']).optional(),
|
|
9
|
+
cursor: z.string().optional(),
|
|
10
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
11
|
+
},
|
|
12
|
+
}, async (input) => {
|
|
13
|
+
const params = new URLSearchParams();
|
|
14
|
+
for (const [k, v] of Object.entries(input)) {
|
|
15
|
+
if (v !== undefined)
|
|
16
|
+
params.set(k, String(v));
|
|
17
|
+
}
|
|
18
|
+
const qs = params.toString();
|
|
19
|
+
const result = await client.get(`/api/v1/runs${qs ? `?${qs}` : ''}`);
|
|
20
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export class VaultApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
// Thin HTTP client — every call forwards the end user's own API key as a
|
|
9
|
+
// Bearer token and passes the vault API's error message through verbatim
|
|
10
|
+
// (never swallowed), so a tool caller can explain the real failure.
|
|
11
|
+
export class VaultClient {
|
|
12
|
+
apiKey;
|
|
13
|
+
baseUrl;
|
|
14
|
+
constructor(apiKey, baseUrl = 'https://vault.norc.app') {
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
this.baseUrl = baseUrl;
|
|
17
|
+
}
|
|
18
|
+
async request(method, path, body) {
|
|
19
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
20
|
+
method,
|
|
21
|
+
headers: {
|
|
22
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
},
|
|
25
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
26
|
+
});
|
|
27
|
+
if (res.status === 204)
|
|
28
|
+
return undefined;
|
|
29
|
+
const data = await res.json().catch(() => ({}));
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new VaultApiError(data.error ?? `request failed (${res.status})`, res.status);
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
get(path) {
|
|
36
|
+
return this.request('GET', path);
|
|
37
|
+
}
|
|
38
|
+
post(path, body) {
|
|
39
|
+
return this.request('POST', path, body);
|
|
40
|
+
}
|
|
41
|
+
patch(path, body) {
|
|
42
|
+
return this.request('PATCH', path, body);
|
|
43
|
+
}
|
|
44
|
+
delete(path) {
|
|
45
|
+
return this.request('DELETE', path);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "norc-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "MCP server for connecting AI platforms to your norc account — manage cron jobs, machines, and run history.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "dist/server.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"norc-mcp": "dist/server.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"!dist/**/*.test.*",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/norcapp/norc-mcp.git"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"packageManager": "pnpm@9.15.9",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"dev": "tsx src/server.ts",
|
|
31
|
+
"test": "vitest run"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
35
|
+
"zod": "^4.6.7"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
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"
|
|
43
|
+
}
|
|
44
|
+
}
|