norc-mcp 0.0.0-stage → 0.1.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/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,42 @@
1
- # Temporary Holding Version
1
+ # norc-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
+ 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. Add this server to your MCP client's connector config, pointing at `https://mcp.norc.app/mcp` with your key as the Bearer token (exact field depends on the client).
9
+
10
+ ## Tools
11
+
12
+ - `list_jobs`, `create_job`, `update_job`, `delete_job`
13
+ - `list_machines`, `rename_machine`, `delete_machine`
14
+ - `list_runs`
15
+
16
+ ## Self-hosting
17
+
18
+ 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
+
20
+ ```bash
21
+ pnpm install
22
+ pnpm build
23
+ PORT=3000 node dist/server.js
24
+ ```
25
+
26
+ Note: self-hosting this server does not mean self-hosting norc itself — it still talks to the hosted norc API.
27
+
28
+ ## Run via Docker
29
+
30
+ ```bash
31
+ docker run -p 3000:3000 norcapp/norc-mcp
32
+ ```
33
+
34
+ Set `NORC_VAULT_URL` if you're pointing at a non-default vault instance:
35
+
36
+ ```bash
37
+ docker run -p 3000:3000 -e NORC_VAULT_URL=https://vault.norc.app norcapp/norc-mcp
38
+ ```
39
+
40
+ ## License
41
+
42
+ 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,43 @@
1
1
  {
2
2
  "name": "norc-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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.0.0",
35
+ "zod": "^4.4.3"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.5.0",
39
+ "tsx": "^4.0.0",
40
+ "vitest": "^2.0.0",
41
+ "@types/node": "^22.0.0"
42
+ }
43
+ }