scc-mcp 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/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # scc-mcp
2
+
3
+ A stdio [MCP](https://modelcontextprotocol.io) server that bridges the
4
+ Scarborough Community Choir API into any MCP client (Claude Desktop, etc.).
5
+
6
+ Everything the AI can do is defined **server-side** in the API's `/ai/manifest`
7
+ endpoint and only handed to a valid API key. This package ships no knowledge of
8
+ the API surface itself — it's a generic manifest→REST bridge.
9
+
10
+ ## 1. Get an API key
11
+
12
+ Sign in to the admin, open the **AI Access** page, and generate a key. Choose a
13
+ read-only or read/write scope as appropriate. Copy the key — it's shown once.
14
+
15
+ ## 2. Install into Claude Desktop
16
+
17
+ The interactive installer locates Claude Desktop's config, asks for the API URL
18
+ and your key, and merges in an `scc` server (keeping any servers you already
19
+ have):
20
+
21
+ ```sh
22
+ npx scc-mcp install
23
+ ```
24
+
25
+ Then **fully quit and reopen Claude Desktop**. The choir tools appear once it
26
+ reconnects.
27
+
28
+ ### Manual setup
29
+
30
+ Alternatively, edit Claude Desktop's config yourself:
31
+
32
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
33
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
34
+
35
+ Add an `scc` entry under `mcpServers` (create the file/object if missing):
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "scc": {
41
+ "command": "npx",
42
+ "args": ["-y", "scc-mcp"],
43
+ "env": {
44
+ "SCC_API_URL": "http://localhost:3200",
45
+ "SCC_API_KEY": "your-key-here"
46
+ }
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Restart Claude Desktop after saving.
53
+
54
+ ## Configuration
55
+
56
+ | Variable | Default | Description |
57
+ | ------------- | ----------------------- | ------------------------------------ |
58
+ | `SCC_API_KEY` | _(required)_ | Key from the admin AI Access page. |
59
+ | `SCC_API_URL` | `http://localhost:3200` | Base URL of the choir API. |
60
+
61
+ Running the server with `SCC_API_KEY` unset prints a help message and exits
62
+ non-zero.
63
+
64
+ ## Bin
65
+
66
+ - `scc-mcp` — the MCP server (run over stdio by the client).
67
+ Run `scc-mcp install` to launch the interactive Claude Desktop installer instead.
package/dist/client.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Thin HTTP client over the Scarborough Community Choir REST API. Every request
3
+ * carries the AI's API key, so the server applies the owning user's roles and
4
+ * read-only scope — this client never makes authorization decisions itself.
5
+ */
6
+ export class ApiClient {
7
+ baseUrl;
8
+ apiKey;
9
+ userAgent;
10
+ constructor(baseUrl, apiKey, version) {
11
+ // Normalise so `baseUrl + path` never doubles or drops a slash.
12
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
13
+ this.apiKey = apiKey;
14
+ this.userAgent = `scc-mcp/${version}`;
15
+ }
16
+ async request(method, path, opts = {}) {
17
+ const url = new URL(this.baseUrl + (path.startsWith("/") ? path : `/${path}`));
18
+ if (opts.query) {
19
+ for (const [k, v] of Object.entries(opts.query)) {
20
+ if (v !== undefined && v !== null && v !== "")
21
+ url.searchParams.set(k, String(v));
22
+ }
23
+ }
24
+ const headers = {
25
+ "X-Api-Key": this.apiKey,
26
+ Accept: "application/json",
27
+ "User-Agent": this.userAgent,
28
+ };
29
+ let body;
30
+ // GET never carries a body; every other method (incl. DELETE, e.g.
31
+ // /tags/assign) may, so send it whenever one was provided.
32
+ if (opts.body !== undefined && method !== "GET") {
33
+ headers["Content-Type"] = "application/json";
34
+ body = JSON.stringify(opts.body);
35
+ }
36
+ const res = await fetch(url, { method, headers, body });
37
+ const contentType = res.headers.get("content-type") ?? "";
38
+ if (contentType.includes("application/json")) {
39
+ return { ok: res.ok, status: res.status, data: await res.json(), contentType };
40
+ }
41
+ if (contentType.startsWith("text/") || contentType.includes("charset")) {
42
+ return { ok: res.ok, status: res.status, text: await res.text(), contentType };
43
+ }
44
+ // Binary (PDF / ZIP / image): don't pull bytes into the model context.
45
+ const buf = await res.arrayBuffer();
46
+ return {
47
+ ok: res.ok,
48
+ status: res.status,
49
+ text: `<binary ${contentType || "response"}, ${buf.byteLength} bytes — not returned; download via the web app>`,
50
+ contentType,
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,78 @@
1
+ /** Resolve {placeholders} in the path and return the args not consumed by it. */
2
+ function fillPath(template, args) {
3
+ const used = new Set();
4
+ const path = template.replace(/\{(\w+)\}/g, (_m, key) => {
5
+ used.add(key);
6
+ return encodeURIComponent(String(args[key] ?? ""));
7
+ });
8
+ const rest = {};
9
+ for (const [k, v] of Object.entries(args))
10
+ if (!used.has(k))
11
+ rest[k] = v;
12
+ return { path, rest };
13
+ }
14
+ /** Build the body for an `in: "body"` tool, honouring `spread` and keeping nulls. */
15
+ function buildBody(rest, spread) {
16
+ const out = {};
17
+ for (const [k, v] of Object.entries(rest)) {
18
+ if (k === spread)
19
+ continue;
20
+ if (v !== undefined)
21
+ out[k] = v; // keep null — it can be meaningful (e.g. unassign)
22
+ }
23
+ if (spread) {
24
+ const extra = rest[spread];
25
+ if (extra && typeof extra === "object")
26
+ Object.assign(out, extra);
27
+ }
28
+ return out;
29
+ }
30
+ function buildRequest(exec, args) {
31
+ // api_request — method/path/query/body come straight from the args.
32
+ if (exec.mode === "passthrough") {
33
+ return {
34
+ method: String(args.method ?? "GET"),
35
+ path: String(args.path ?? "/"),
36
+ query: args.query,
37
+ body: args.body,
38
+ };
39
+ }
40
+ const { path, rest } = fillPath(exec.path, args);
41
+ const req = { method: exec.method, path };
42
+ if (exec.bodyFrom) {
43
+ req.body = args[exec.bodyFrom]; // the whole body is one arg (e.g. an update's `fields`)
44
+ }
45
+ else if (exec.in === "body") {
46
+ req.body = buildBody(rest, exec.spread);
47
+ }
48
+ else if (exec.in === "query") {
49
+ const query = {};
50
+ for (const [k, v] of Object.entries(rest))
51
+ if (v !== undefined)
52
+ query[k] = v;
53
+ req.query = query;
54
+ }
55
+ return req;
56
+ }
57
+ export async function execute(client, exec, args) {
58
+ const { method, path, query, body } = buildRequest(exec, args);
59
+ return client.request(method, path, { query, body });
60
+ }
61
+ /** Format an API result as MCP tool-call content. */
62
+ export function toContent(r) {
63
+ const payload = r.data !== undefined ? r.data : (r.text ?? "");
64
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
65
+ if (r.ok)
66
+ return { content: [{ type: "text", text }] };
67
+ const hint = r.status === 401
68
+ ? "(The API key is missing, invalid, or revoked.)"
69
+ : r.status === 403
70
+ ? "(The key's user lacks permission for this, or it is a read-only key being used for a write.)"
71
+ : "";
72
+ return {
73
+ isError: true,
74
+ content: [
75
+ { type: "text", text: `Request failed — HTTP ${r.status}.\n${text}\n\n${hint}` },
76
+ ],
77
+ };
78
+ }
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { ApiClient } from "./client.js";
6
+ import { execute, toContent } from "./dispatch.js";
7
+ // `scc-mcp install` runs the Claude Desktop installer; with no subcommand we
8
+ // start the MCP server below. A single bin keeps `npx scc-mcp [install]`
9
+ // unambiguous (npx can't choose between multiple bins).
10
+ if (process.argv[2] === "install") {
11
+ const { runInstaller } = await import("./install.js");
12
+ await runInstaller().catch((err) => {
13
+ console.error(`\n[scc-mcp] ${err instanceof Error ? err.message : String(err)}`);
14
+ process.exit(1);
15
+ });
16
+ process.exit(0);
17
+ }
18
+ const VERSION = "0.1.0";
19
+ const API_URL = process.env.SCC_API_URL ?? "http://localhost:3200";
20
+ const API_KEY = process.env.SCC_API_KEY ?? "";
21
+ if (!API_KEY) {
22
+ // stderr, not stdout — stdout is the MCP protocol channel.
23
+ console.error("[scc-mcp] Missing SCC_API_KEY. Generate a key in the admin under AI Access,\n" +
24
+ "then set SCC_API_KEY (and optionally SCC_API_URL, default http://localhost:3200).");
25
+ process.exit(1);
26
+ }
27
+ const client = new ApiClient(API_URL, API_KEY, VERSION);
28
+ // The package knows exactly one endpoint. Everything the AI can do is defined
29
+ // server-side and only handed to a valid API key — so nothing about the API
30
+ // surface ships in this (publishable) package.
31
+ const res = await client.request("GET", "/ai/manifest");
32
+ if (!res.ok || !res.data || typeof res.data !== "object") {
33
+ const hint = res.status === 401
34
+ ? "the API key is missing, invalid, or revoked."
35
+ : res.status === 404
36
+ ? `the API at ${API_URL} doesn't expose /ai/manifest — update it to a version with MCP support.`
37
+ : `HTTP ${res.status} from ${API_URL}/ai/manifest.`;
38
+ console.error(`[scc-mcp] Could not load the tool manifest: ${hint}`);
39
+ process.exit(1);
40
+ }
41
+ const manifest = res.data;
42
+ const byName = new Map(manifest.tools.map((t) => [t.name, t]));
43
+ const server = new Server({ name: "scarborough-community-choir", version: VERSION }, { capabilities: { tools: {} }, instructions: manifest.instructions });
44
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
45
+ tools: manifest.tools.map((t) => ({
46
+ name: t.name,
47
+ description: t.description,
48
+ // Manifest carries JSON Schema directly (built server-side from zod).
49
+ inputSchema: t.inputSchema,
50
+ ...(t.annotations ? { annotations: t.annotations } : {}),
51
+ })),
52
+ }));
53
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
54
+ const tool = byName.get(req.params.name);
55
+ if (!tool) {
56
+ return {
57
+ isError: true,
58
+ content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
59
+ };
60
+ }
61
+ const args = (req.params.arguments ?? {});
62
+ return toContent(await execute(client, tool.exec, args));
63
+ });
64
+ const transport = new StdioServerTransport();
65
+ await server.connect(transport);
66
+ console.error(`[scc-mcp] connected — API ${API_URL} · ${manifest.tools.length} tools (manifest v${manifest.version})`);
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive installer for the Scarborough Community Choir MCP server.
4
+ *
5
+ * It locates the Claude Desktop config, prompts for the API URL + key, merges
6
+ * an `scc` entry into `mcpServers` (preserving any existing servers), and writes
7
+ * it back with a backup. Only the Node standard library is used.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir, platform } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+ import { createInterface } from "node:readline";
13
+ const DEFAULT_API_URL = "http://localhost:3200";
14
+ /** Locate Claude Desktop's config file for the current platform. */
15
+ function configPath() {
16
+ const home = homedir();
17
+ switch (platform()) {
18
+ case "darwin":
19
+ return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
20
+ case "win32":
21
+ return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
22
+ default:
23
+ // Linux / other — Claude Desktop isn't officially shipped here, but
24
+ // follow the same XDG-ish convention so the file is at least sensible.
25
+ return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "Claude", "claude_desktop_config.json");
26
+ }
27
+ }
28
+ /** Read + parse the config, tolerating a missing or empty file. Returns {} when absent. */
29
+ function readConfig(path) {
30
+ if (!existsSync(path))
31
+ return {};
32
+ let raw;
33
+ try {
34
+ raw = readFileSync(path, "utf8").trim();
35
+ }
36
+ catch (err) {
37
+ throw new Error(`Could not read ${path}: ${err.message}`);
38
+ }
39
+ if (raw === "")
40
+ return {};
41
+ try {
42
+ const parsed = JSON.parse(raw);
43
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
44
+ return parsed;
45
+ throw new Error("config is not a JSON object");
46
+ }
47
+ catch (err) {
48
+ throw new Error(`The existing config at ${path} is not valid JSON (${err.message}).\nFix or remove it, then re-run the installer.`);
49
+ }
50
+ }
51
+ /**
52
+ * Ask a question. Resolves `null` if the input stream closes (EOF) before an
53
+ * answer is given. We consume `line`/`close` events directly rather than via
54
+ * `rl.question`'s callback so that, when stdin is a pipe and both a buffered
55
+ * line and `close` are queued together, the line always wins — `rl.question`
56
+ * alone races and can drop the final line (or hang on a closed pipe).
57
+ */
58
+ /**
59
+ * A line-at-a-time prompter built on a *persistent* `line` listener feeding a
60
+ * queue. A per-question listener loses lines a buffered pipe emits back-to-back
61
+ * (and `rl.question` races `line` vs `close` on a closed pipe); buffering avoids
62
+ * both. `ask` resolves the next queued line, or `null` once input is exhausted.
63
+ */
64
+ function makePrompter(rl, out) {
65
+ const lines = [];
66
+ let waiting = null;
67
+ let closed = false;
68
+ const deliver = () => {
69
+ if (!waiting)
70
+ return;
71
+ if (lines.length > 0) {
72
+ const resolve = waiting;
73
+ waiting = null;
74
+ resolve(lines.shift());
75
+ }
76
+ else if (closed) {
77
+ const resolve = waiting;
78
+ waiting = null;
79
+ resolve(null);
80
+ }
81
+ };
82
+ rl.on("line", (line) => {
83
+ lines.push(line.trim());
84
+ deliver();
85
+ });
86
+ rl.once("close", () => {
87
+ closed = true;
88
+ // Let any already-queued `line` events settle first, then flush EOF.
89
+ setImmediate(deliver);
90
+ });
91
+ return {
92
+ get closed() {
93
+ return closed && lines.length === 0;
94
+ },
95
+ ask(question) {
96
+ out.write(question);
97
+ if (lines.length > 0)
98
+ return Promise.resolve(lines.shift());
99
+ if (closed)
100
+ return Promise.resolve(null);
101
+ return new Promise((resolve) => {
102
+ waiting = resolve;
103
+ });
104
+ },
105
+ };
106
+ }
107
+ export async function runInstaller() {
108
+ const path = configPath();
109
+ console.log("Scarborough Community Choir — Claude Desktop MCP installer\n");
110
+ console.log(`Config file: ${path}\n`);
111
+ // Read first so a malformed existing file aborts before we prompt.
112
+ const config = readConfig(path);
113
+ const out = process.stdout;
114
+ // No `output` on the interface: prompts are written via the prompter so the
115
+ // answer arrives on the buffered `line` queue. `terminal: false` keeps
116
+ // readline from echoing or rewriting lines.
117
+ const rl = createInterface({ input: process.stdin, terminal: false });
118
+ const prompter = makePrompter(rl, out);
119
+ try {
120
+ const urlAnswer = await prompter.ask(`SCC API URL [${DEFAULT_API_URL}]: `);
121
+ const apiUrl = urlAnswer?.trim() || DEFAULT_API_URL;
122
+ let apiKey = "";
123
+ // Re-prompt while empty, but stop if the input stream is exhausted (EOF) —
124
+ // otherwise we'd loop forever with no way to answer.
125
+ while (!apiKey && !prompter.closed) {
126
+ const answer = await prompter.ask("SCC_API_KEY (from the admin → AI Access page): ");
127
+ apiKey = (answer ?? "").trim();
128
+ if (!apiKey && !prompter.closed) {
129
+ out.write(" An API key is required. Generate one in the admin under AI Access.\n");
130
+ }
131
+ }
132
+ if (!apiKey) {
133
+ throw new Error("No API key was provided. Re-run and paste a key from the admin AI Access page.");
134
+ }
135
+ config.mcpServers ??= {};
136
+ const servers = config.mcpServers;
137
+ if (servers.scc) {
138
+ console.log("\nAn existing 'scc' MCP server entry was found and will be replaced.");
139
+ }
140
+ servers.scc = {
141
+ command: "npx",
142
+ args: ["-y", "scc-mcp"],
143
+ env: { SCC_API_URL: apiUrl, SCC_API_KEY: apiKey },
144
+ };
145
+ // Back up any existing file before overwriting.
146
+ if (existsSync(path)) {
147
+ const backup = `${path}.bak`;
148
+ try {
149
+ writeFileSync(backup, readFileSync(path));
150
+ console.log(`\nBacked up existing config to ${backup}`);
151
+ }
152
+ catch (err) {
153
+ console.warn(` Warning: could not write backup (${err.message}).`);
154
+ }
155
+ }
156
+ else {
157
+ mkdirSync(dirname(path), { recursive: true });
158
+ }
159
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
160
+ console.log("\nDone. The 'scc' MCP server is configured.\n");
161
+ console.log("Next steps:");
162
+ console.log(" 1. Fully quit Claude Desktop (Cmd+Q on macOS), then reopen it.");
163
+ console.log(" 2. The choir tools will appear once it reconnects.");
164
+ console.log(`\n API URL: ${apiUrl}`);
165
+ }
166
+ finally {
167
+ rl.close();
168
+ }
169
+ }
170
+ // Invoked via the `scc-mcp install` subcommand (see index.ts), not run directly.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "scc-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Key-based MCP server exposing the Scarborough Community Choir API so an AI can query data and act like an assistant.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/AccentDesign/ScarboroughCommunityChoir-Hono-Astro.git",
9
+ "directory": "apps/mcp"
10
+ },
11
+ "bin": {
12
+ "scc-mcp": "./dist/index.js"
13
+ },
14
+ "main": "./dist/index.js",
15
+ "files": ["dist", "README.md"],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "dev": "tsx src/index.ts",
21
+ "start": "node dist/index.js",
22
+ "install:claude": "node dist/index.js install",
23
+ "build": "tsc",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.29.0",
28
+ "zod": "^3.24.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "tsx": "^4.21.0",
33
+ "typescript": "^5.7.0"
34
+ }
35
+ }