clyops-mcp 0.0.0-stage → 0.2.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 CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ # clyops-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
+ Give an AI agent a directory of [clyops](https://github.com/wankdanker/clyops) tools over the
4
+ [Model Context Protocol](https://modelcontextprotocol.io). Every clyops tool becomes an MCP tool:
5
+ its description and input schema come from its own `--help-json-schema`, so the agent sees the same
6
+ options, types, choices and ranges the tool validates, and nothing needs to be written per tool.
7
+
8
+ ```sh
9
+ npm install -g clyops-mcp
10
+ ```
11
+
12
+ ## Local agent (stdio)
13
+
14
+ ```sh
15
+ claude mcp add mytool -- clyops-mcp --root ~/mytool/scripts
16
+ ```
17
+
18
+ or in any client's MCP config:
19
+
20
+ ```json
21
+ { "mcpServers": { "mytool": { "command": "clyops-mcp", "args": ["--root", "/home/me/mytool/scripts"] } } }
22
+ ```
23
+
24
+ ```
25
+ scripts/
26
+ .clyops (description: My tools) → the server's instructions to the agent
27
+ check.sh → tool "check"
28
+ media/
29
+ to-pcm.sh → tool "media_to-pcm"
30
+ ```
31
+
32
+ The directory is read like [clyops-dispatch](../dispatch) reads it, and `--root` may also be a
33
+ dispatcher definition file. Only programs built on a clyops library are offered.
34
+
35
+ A call's arguments are validated against the tool's schema before anything runs and mapped onto
36
+ its command line ([spec §13](../../spec/SPEC.md#13-json-input-toargv)). The agent gets the tool's
37
+ stdout back; when stdout is a JSON object it is also returned as structured content. A tool that
38
+ fails comes back as an error with its exit status and stderr, so the agent can read the tool's own
39
+ message and correct itself.
40
+
41
+ - **Commands.** A program with commands (`tasks db migrate`) gives one MCP tool per command:
42
+ `tasks_db_migrate`.
43
+ - **Effects.** A tool's declared effects become MCP annotations: `read-only` → `readOnlyHint`,
44
+ `destructive` → `destructiveHint`, `idempotent` → `idempotentHint`, `network` →
45
+ `openWorldHint`. Clients use them to decide when to ask before running a tool.
46
+ - **stdin and stdout.** A tool that declares stdin takes it as one more argument, `stdin` (base64
47
+ for binary types). Declared binary stdout comes back as an `image` or `audio` content block, or
48
+ an embedded resource (a blob) for other types.
49
+ - **Secrets.** Secret options are passed to the tool in its environment rather than on the command
50
+ line, and shown as `***` in logs.
51
+
52
+ ```
53
+ clyops-mcp --root DIR [--name NAME] [--cwd DIR] [--timeout SECONDS] [--no-watch]
54
+ [--allow GLOB]... [--deny GLOB]... [--read-only] [--paths-within DIR]...
55
+ [--max-output BYTES] [--audit FILE]
56
+ ```
57
+
58
+ The server watches the tools directory: when a tool is added, changed or removed it tells the
59
+ agent (`notifications/tools/list_changed`), and clients that support it refresh their tool list
60
+ without restarting the server. `--no-watch` reads the tools once at startup.
61
+
62
+ Tools run in `--cwd` (default: where the server was started, which for most clients is the
63
+ project the agent is working in), so relative paths resolve there. Options can also be set as
64
+ `CLYOPS_MCP_<OPTION>` in the environment.
65
+
66
+ ## Security
67
+
68
+ The agent can run any tool the server offers with any arguments its schema accepts. Offer only
69
+ what it should be able to run:
70
+
71
+ - `--allow media/*` / `--deny admin/**` (repeatable): globs over a tool's words, `*` within a word
72
+ and `**` across words. A tool must match an `--allow` pattern when there are any, and no
73
+ `--deny` pattern. `allow:` and `deny:` lines in the root `.clyops` file apply as well.
74
+ - `--read-only`: offer only tools that declare the `read-only` effect.
75
+ - `--paths-within DIR` (repeatable): path arguments (`path`, `file:*`, `dir:*`) must resolve inside
76
+ one of these directories, symlinks followed, before anything runs.
77
+ - `--max-output BYTES` (default 16 MiB): keep at most this much of a tool's stdout and stderr.
78
+ - `--audit FILE` (`-` for stderr): one JSON line per run with the tool, its command line (secrets
79
+ as `***`), exit status and duration.
80
+
81
+ ## Over HTTP
82
+
83
+ [clyops-api](../api) serves the same tools over MCP's streamable HTTP transport at `/mcp`, next to
84
+ its REST endpoints and behind the same API key:
85
+
86
+ ```sh
87
+ clyops-api --root ~/mytool/scripts --api-key "$KEY"
88
+ claude mcp add --transport http mytool http://127.0.0.1:8080/mcp --header "Authorization: Bearer $KEY"
89
+ ```
90
+
91
+ Or mount it in your own Express app:
92
+
93
+ ```js
94
+ import express from 'express';
95
+ import { loadTools } from 'clyops-tools';
96
+ import { mcpHttpHandler } from 'clyops-mcp';
97
+
98
+ const { tree, tools } = await loadTools('./scripts');
99
+ const app = express();
100
+ app.use(express.json());
101
+ app.post('/mcp', mcpHttpHandler({ name: tree.name, tools }));
102
+ app.listen(8080);
103
+ ```
104
+
105
+ `createMcpServer({name, tools, ...})` returns the SDK `Server` for any other transport. Each HTTP
106
+ request is handled statelessly with its own server instance.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // clyops-mcp --root DIR: a stdio MCP server for a directory of clyops tools.
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { Cli, warn } from 'clyops';
5
+ import { auditLog, loadTools, watchTools } from 'clyops-tools';
6
+ import { readFileSync } from 'node:fs';
7
+ import { createMcpServer } from './server.js';
8
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
+ const cli = new Cli({ name: 'clyops-mcp' });
10
+ cli.setDescription('Serve a directory of clyops tools to an AI agent over MCP (stdio): one MCP tool per clyops tool, with its input schema from the tool\'s --help-json-schema.');
11
+ cli.setEpilog('Every option can also be set in the environment as CLYOPS_MCP_<OPTION>.\n\nExamples:\n claude mcp add mytool -- clyops-mcp --root ~/mytool/scripts\n { "mcpServers": { "mytool": { "command": "clyops-mcp", "args": ["--root", "/home/me/mytool/scripts"] } } }');
12
+ cli.opt('CLYOPS_MCP_ROOT', 'root', 'r', '', 'Tools directory or dispatcher definition file', 'Tools', 'path');
13
+ cli.opt('CLYOPS_MCP_NAME', 'name', 'n', 'optional', 'Server name (default: the directory name)', 'Tools');
14
+ cli.opt('CLYOPS_MCP_CWD', 'cwd', '', 'optional', 'Working directory for the tools (default: the current one)', 'Tools', 'dir:exists');
15
+ cli.opt('CLYOPS_MCP_TIMEOUT', 'timeout', 't', '0', 'Kill a tool after this many seconds (0: never)', 'Tools', 'int:0-');
16
+ cli.opt('CLYOPS_MCP_WATCH', 'watch', 'w', 'true', 'Pick up added, changed and removed tools without a restart', 'Tools', 'bool');
17
+ cli.optArray('CLYOPS_MCP_ALLOW', 'allow', 'a', 'Serve only tools matching this glob over their words (media/*, media/**)', 'Security');
18
+ cli.optArray('CLYOPS_MCP_DENY', 'deny', 'D', 'Leave out tools matching this glob', 'Security');
19
+ cli.opt('CLYOPS_MCP_READ_ONLY', 'read-only', '', 'flag', 'Serve only tools that declare the read-only effect', 'Security');
20
+ cli.optArray('CLYOPS_MCP_PATHS_WITHIN', 'paths-within', '', 'Path arguments must resolve inside this directory', 'Security', 'dir:exists');
21
+ cli.opt('CLYOPS_MCP_MAX_OUTPUT', 'max-output', '', '16777216', 'Keep at most this many bytes of a tool\'s stdout and stderr (0: all)', 'Security', 'int:0-');
22
+ cli.opt('CLYOPS_MCP_AUDIT', 'audit', '', 'optional', 'Append a JSON line per run to this file (-: stderr)', 'Security', 'path');
23
+ const args = cli.run();
24
+ // stdout is the MCP channel; clyops logs to stderr.
25
+ const onError = (cmd, err) => warn('%s%s', cmd ? `skipping ${cmd.words.join(' ')}: ` : '', err.message);
26
+ const filter = { allow: args.CLYOPS_MCP_ALLOW, deny: args.CLYOPS_MCP_DENY, readOnly: args.CLYOPS_MCP_READ_ONLY };
27
+ const opts = { name: args.CLYOPS_MCP_NAME ?? undefined, filter, onError };
28
+ const within = args.CLYOPS_MCP_PATHS_WITHIN;
29
+ const watcher = args.CLYOPS_MCP_WATCH
30
+ ? await watchTools(args.CLYOPS_MCP_ROOT, {
31
+ ...opts,
32
+ // The server reads mcp.tools on each request; tell the agent the list changed.
33
+ onChange: ({ tools }) => {
34
+ mcp.tools = tools;
35
+ void server.sendToolListChanged();
36
+ },
37
+ })
38
+ : undefined;
39
+ const { tree, tools } = watcher ? watcher.current() : await loadTools(args.CLYOPS_MCP_ROOT, opts);
40
+ const mcp = {
41
+ name: tree.name,
42
+ version,
43
+ instructions: tree.description || undefined,
44
+ tools,
45
+ cwd: args.CLYOPS_MCP_CWD ?? undefined,
46
+ timeoutMs: args.CLYOPS_MCP_TIMEOUT * 1000,
47
+ within: within.length ? within : undefined,
48
+ maxOutput: args.CLYOPS_MCP_MAX_OUTPUT,
49
+ audit: args.CLYOPS_MCP_AUDIT ? auditLog(args.CLYOPS_MCP_AUDIT) : undefined,
50
+ };
51
+ const server = createMcpServer(mcp);
52
+ // The watcher would keep the process alive after the agent disconnects, and
53
+ // the stdio transport doesn't close by itself when stdin ends.
54
+ server.onclose = () => watcher?.close();
55
+ process.stdin.on('end', () => void server.close());
56
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1 @@
1
+ export * from './server.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // clyops-mcp: a directory of clyops tools as MCP tools, over stdio or streamable HTTP.
2
+ export * from './server.js';
@@ -0,0 +1,38 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { type AuditEntry, type Tool } from 'clyops-tools';
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ export interface McpOptions {
5
+ /** Server name shown to clients (e.g. the tools directory's name). */
6
+ name: string;
7
+ version?: string;
8
+ /** Instructions for the agent, e.g. the tools directory's description. */
9
+ instructions?: string;
10
+ tools: Tool[];
11
+ /** Working directory tools run in (default: the server's). */
12
+ cwd?: string;
13
+ /** Kill a tool after this long (0: never). */
14
+ timeoutMs?: number;
15
+ /** Path-valued arguments must resolve inside these directories. */
16
+ within?: string[];
17
+ /** Keep at most this many bytes of a tool's stdout and stderr (0: all). */
18
+ maxOutput?: number;
19
+ /** Called after every run, for an audit log. */
20
+ audit?: (entry: Omit<AuditEntry, 'time'>) => void;
21
+ }
22
+ /** The MCP tool name for a clyops tool: its words joined by `_` (`media_to-pcm`). */
23
+ export declare function toolName(tool: Tool): string;
24
+ /**
25
+ * The SDK server for `opts`. It reads `opts.tools` on every request, so
26
+ * replacing it (then calling `server.sendToolListChanged()` on a connected
27
+ * server) updates the tools without a restart.
28
+ */
29
+ export declare function createMcpServer(opts: McpOptions): Server;
30
+ /**
31
+ * A request handler serving MCP over streamable HTTP, statelessly: each
32
+ * request gets its own server and transport. Mount it on POST (and GET/DELETE,
33
+ * which it answers with 405) at a path such as `/mcp`, after a JSON body parser.
34
+ * Pass a function to serve whatever it returns for each request (given the request, e.g. to serve per-caller tools).
35
+ */
36
+ export declare function mcpHttpHandler<R extends IncomingMessage & {
37
+ body?: unknown;
38
+ }>(opts: McpOptions | ((req: R) => McpOptions)): (req: R, res: ServerResponse) => Promise<void>;
package/dist/server.js ADDED
@@ -0,0 +1,146 @@
1
+ // An MCP server over a directory of clyops tools: one MCP tool per clyops
2
+ // tool, its input schema from the tool's --help-json-schema (spec section 13).
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
6
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
+ import { InputError, isTextType, runTool, tail, toJsonSchema } from 'clyops-tools';
8
+ /** The MCP tool name for a clyops tool: its words joined by `_` (`media_to-pcm`). */
9
+ export function toolName(tool) {
10
+ return tool.words.join('_').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
11
+ }
12
+ /** A tool's declared binary stdout type, if any (spec section 1.3). */
13
+ function binaryStdout(tool) {
14
+ const type = tool.schema.stdout?.contentType;
15
+ return type && !isTextType(type) ? type.split(';')[0].trim() : undefined;
16
+ }
17
+ /**
18
+ * What the agent reads back from a run: stdout (an image, audio or a blob for
19
+ * declared binary output), and on failure the exit status and stderr.
20
+ */
21
+ function toResult(tool, result) {
22
+ const content = [];
23
+ const binary = binaryStdout(tool);
24
+ if (binary && result.stdoutBuffer?.length) {
25
+ const data = result.stdoutBuffer.toString('base64');
26
+ if (binary.startsWith('image/'))
27
+ content.push({ type: 'image', data, mimeType: binary });
28
+ else if (binary.startsWith('audio/'))
29
+ content.push({ type: 'audio', data, mimeType: binary });
30
+ else
31
+ content.push({ type: 'resource', resource: { uri: `clyops://${toolName(tool)}/stdout`, mimeType: binary, blob: data } });
32
+ }
33
+ if (result.stdout)
34
+ content.push({ type: 'text', text: result.stdout });
35
+ if (!result.ok) {
36
+ const why = result.timedOut ? 'timed out' : result.signal ? `was killed (${result.signal})` : `exited with status ${result.exitCode}`;
37
+ // All of stderr: a tool's error line often comes before its usage text.
38
+ content.push({ type: 'text', text: [`${tool.words.join(' ')} ${why}.`, ...tail(result.stderr, Infinity)].join('\n') });
39
+ }
40
+ if (!content.length)
41
+ content.push({ type: 'text', text: '(no output)' });
42
+ const json = result.json;
43
+ return {
44
+ content,
45
+ isError: !result.ok,
46
+ ...(json && typeof json === 'object' && !Array.isArray(json) ? { structuredContent: json } : {}),
47
+ };
48
+ }
49
+ // Each tool's input schema and compiled validator, built once per tool object.
50
+ // A tool that declares stdin takes it as one more argument, `stdin`.
51
+ const validator = new AjvJsonSchemaValidator();
52
+ const prepared = new WeakMap();
53
+ function prepare(tool) {
54
+ let entry = prepared.get(tool);
55
+ if (!entry) {
56
+ const inputSchema = toJsonSchema(tool.schema);
57
+ const stdin = tool.schema.stdin;
58
+ const properties = inputSchema.properties;
59
+ if (stdin && !('stdin' in properties)) {
60
+ const text = isTextType(stdin.contentType);
61
+ const what = [stdin.description, stdin.contentType && `(${stdin.contentType})`].filter(Boolean).join(' ');
62
+ properties.stdin = { type: 'string', description: `Standard input${what ? `: ${what}` : ''}${text ? '' : ', base64-encoded'}`, ...(text ? {} : { contentEncoding: 'base64' }) };
63
+ }
64
+ entry = { inputSchema, validate: validator.getValidator(inputSchema) };
65
+ prepared.set(tool, entry);
66
+ }
67
+ return entry;
68
+ }
69
+ /** MCP tool annotations from the tool's declared effects (spec section 1.3). */
70
+ function annotations(tool) {
71
+ const effects = tool.schema.effects ?? [];
72
+ const out = {};
73
+ if (effects.includes('read-only'))
74
+ out.readOnlyHint = true;
75
+ if (effects.includes('destructive'))
76
+ out.destructiveHint = true;
77
+ if (effects.includes('idempotent'))
78
+ out.idempotentHint = true;
79
+ if (effects.includes('network'))
80
+ out.openWorldHint = true;
81
+ return Object.keys(out).length ? { annotations: out } : {};
82
+ }
83
+ /**
84
+ * The SDK server for `opts`. It reads `opts.tools` on every request, so
85
+ * replacing it (then calling `server.sendToolListChanged()` on a connected
86
+ * server) updates the tools without a restart.
87
+ */
88
+ export function createMcpServer(opts) {
89
+ const server = new Server({ name: opts.name, version: opts.version ?? '0.0.0' }, { capabilities: { tools: { listChanged: true } }, instructions: opts.instructions });
90
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
91
+ tools: opts.tools.map((tool) => ({
92
+ name: toolName(tool),
93
+ title: tool.words.join(' '),
94
+ description: [tool.schema.description, tool.schema.epilog].filter(Boolean).join('\n\n'),
95
+ inputSchema: prepare(tool).inputSchema,
96
+ ...annotations(tool),
97
+ })),
98
+ }));
99
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
100
+ const tool = opts.tools.find((t) => toolName(t) === request.params.name);
101
+ if (!tool)
102
+ return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
103
+ const checked = prepare(tool).validate(request.params.arguments ?? {});
104
+ if (!checked.valid)
105
+ return { content: [{ type: 'text', text: `Invalid arguments: ${checked.errorMessage}` }], isError: true };
106
+ const { stdin, ...input } = checked.data;
107
+ const declared = tool.schema.stdin;
108
+ const stdinData = declared && typeof stdin === 'string' ? (isTextType(declared.contentType) ? stdin : Buffer.from(stdin, 'base64')) : undefined;
109
+ let result;
110
+ try {
111
+ result = await runTool(tool, declared ? input : checked.data, {
112
+ cwd: opts.cwd, timeoutMs: opts.timeoutMs, signal: extra.signal, within: opts.within, maxOutput: opts.maxOutput,
113
+ stdin: stdinData, stdout: binaryStdout(tool) ? 'buffer' : 'text',
114
+ });
115
+ }
116
+ catch (err) {
117
+ if (err instanceof InputError)
118
+ return { content: [{ type: 'text', text: `Invalid arguments: ${err.message}` }], isError: true };
119
+ throw err;
120
+ }
121
+ opts.audit?.({
122
+ tool: tool.words.join(' '), command: result.command, exitCode: result.exitCode, signal: result.signal,
123
+ timedOut: result.timedOut, durationMs: result.durationMs, via: 'mcp',
124
+ });
125
+ return toResult(tool, result);
126
+ });
127
+ return server;
128
+ }
129
+ /**
130
+ * A request handler serving MCP over streamable HTTP, statelessly: each
131
+ * request gets its own server and transport. Mount it on POST (and GET/DELETE,
132
+ * which it answers with 405) at a path such as `/mcp`, after a JSON body parser.
133
+ * Pass a function to serve whatever it returns for each request (given the request, e.g. to serve per-caller tools).
134
+ */
135
+ export function mcpHttpHandler(opts) {
136
+ return async (req, res) => {
137
+ const server = createMcpServer(typeof opts === 'function' ? opts(req) : opts);
138
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
139
+ res.on('close', () => {
140
+ void transport.close();
141
+ void server.close();
142
+ });
143
+ await server.connect(transport);
144
+ await transport.handleRequest(req, res, req.body);
145
+ };
146
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "clyops-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Give an AI agent a directory of clyops tools over MCP (stdio or streamable HTTP), with each tool's input schema from its --help-json-schema.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/wankdanker/clyops.git",
9
+ "directory": "apps/mcp"
10
+ },
11
+ "keywords": [
12
+ "clyops",
13
+ "mcp",
14
+ "model-context-protocol",
15
+ "agent",
16
+ "cli",
17
+ "tools"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "clyops-mcp": "./dist/cli.js"
25
+ },
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit",
41
+ "test": "node --test test/*.test.mjs",
42
+ "prepublishOnly": "npm run build && npm test"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.32.1",
46
+ "clyops": "0.2.0",
47
+ "clyops-tools": "0.2.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.0.0",
51
+ "typescript": "^5.6.0",
52
+ "express": "^5.2.1"
53
+ }
54
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // clyops-mcp --root DIR: a stdio MCP server for a directory of clyops tools.
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { Cli, warn } from 'clyops';
5
+ import { auditLog, loadTools, watchTools } from 'clyops-tools';
6
+ import { readFileSync } from 'node:fs';
7
+ import { createMcpServer } from './server.js';
8
+
9
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string };
10
+
11
+ const cli = new Cli({ name: 'clyops-mcp' });
12
+ cli.setDescription('Serve a directory of clyops tools to an AI agent over MCP (stdio): one MCP tool per clyops tool, with its input schema from the tool\'s --help-json-schema.');
13
+ cli.setEpilog('Every option can also be set in the environment as CLYOPS_MCP_<OPTION>.\n\nExamples:\n claude mcp add mytool -- clyops-mcp --root ~/mytool/scripts\n { "mcpServers": { "mytool": { "command": "clyops-mcp", "args": ["--root", "/home/me/mytool/scripts"] } } }');
14
+ cli.opt('CLYOPS_MCP_ROOT', 'root', 'r', '', 'Tools directory or dispatcher definition file', 'Tools', 'path');
15
+ cli.opt('CLYOPS_MCP_NAME', 'name', 'n', 'optional', 'Server name (default: the directory name)', 'Tools');
16
+ cli.opt('CLYOPS_MCP_CWD', 'cwd', '', 'optional', 'Working directory for the tools (default: the current one)', 'Tools', 'dir:exists');
17
+ cli.opt('CLYOPS_MCP_TIMEOUT', 'timeout', 't', '0', 'Kill a tool after this many seconds (0: never)', 'Tools', 'int:0-');
18
+ cli.opt('CLYOPS_MCP_WATCH', 'watch', 'w', 'true', 'Pick up added, changed and removed tools without a restart', 'Tools', 'bool');
19
+ cli.optArray('CLYOPS_MCP_ALLOW', 'allow', 'a', 'Serve only tools matching this glob over their words (media/*, media/**)', 'Security');
20
+ cli.optArray('CLYOPS_MCP_DENY', 'deny', 'D', 'Leave out tools matching this glob', 'Security');
21
+ cli.opt('CLYOPS_MCP_READ_ONLY', 'read-only', '', 'flag', 'Serve only tools that declare the read-only effect', 'Security');
22
+ cli.optArray('CLYOPS_MCP_PATHS_WITHIN', 'paths-within', '', 'Path arguments must resolve inside this directory', 'Security', 'dir:exists');
23
+ cli.opt('CLYOPS_MCP_MAX_OUTPUT', 'max-output', '', '16777216', 'Keep at most this many bytes of a tool\'s stdout and stderr (0: all)', 'Security', 'int:0-');
24
+ cli.opt('CLYOPS_MCP_AUDIT', 'audit', '', 'optional', 'Append a JSON line per run to this file (-: stderr)', 'Security', 'path');
25
+ const args = cli.run();
26
+
27
+ // stdout is the MCP channel; clyops logs to stderr.
28
+ const onError = (cmd: { words: string[] } | null, err: Error) => warn('%s%s', cmd ? `skipping ${cmd.words.join(' ')}: ` : '', err.message);
29
+ const filter = { allow: args.CLYOPS_MCP_ALLOW as string[], deny: args.CLYOPS_MCP_DENY as string[], readOnly: args.CLYOPS_MCP_READ_ONLY as boolean };
30
+ const opts = { name: (args.CLYOPS_MCP_NAME as string | null) ?? undefined, filter, onError };
31
+ const within = args.CLYOPS_MCP_PATHS_WITHIN as string[];
32
+ const watcher = args.CLYOPS_MCP_WATCH
33
+ ? await watchTools(args.CLYOPS_MCP_ROOT as string, {
34
+ ...opts,
35
+ // The server reads mcp.tools on each request; tell the agent the list changed.
36
+ onChange: ({ tools }) => {
37
+ mcp.tools = tools;
38
+ void server.sendToolListChanged();
39
+ },
40
+ })
41
+ : undefined;
42
+ const { tree, tools } = watcher ? watcher.current() : await loadTools(args.CLYOPS_MCP_ROOT as string, opts);
43
+ const mcp = {
44
+ name: tree.name,
45
+ version,
46
+ instructions: tree.description || undefined,
47
+ tools,
48
+ cwd: (args.CLYOPS_MCP_CWD as string | null) ?? undefined,
49
+ timeoutMs: (args.CLYOPS_MCP_TIMEOUT as number) * 1000,
50
+ within: within.length ? within : undefined,
51
+ maxOutput: args.CLYOPS_MCP_MAX_OUTPUT as number,
52
+ audit: args.CLYOPS_MCP_AUDIT ? auditLog(args.CLYOPS_MCP_AUDIT as string) : undefined,
53
+ };
54
+ const server = createMcpServer(mcp);
55
+ // The watcher would keep the process alive after the agent disconnects, and
56
+ // the stdio transport doesn't close by itself when stdin ends.
57
+ server.onclose = () => watcher?.close();
58
+ process.stdin.on('end', () => void server.close());
59
+ await server.connect(new StdioServerTransport());
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // clyops-mcp: a directory of clyops tools as MCP tools, over stdio or streamable HTTP.
2
+ export * from './server.js';
package/src/server.ts ADDED
@@ -0,0 +1,164 @@
1
+ // An MCP server over a directory of clyops tools: one MCP tool per clyops
2
+ // tool, its input schema from the tool's --help-json-schema (spec section 13).
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
6
+ import type { JsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/types.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
8
+ import { InputError, isTextType, runTool, tail, toJsonSchema, type AuditEntry, type JsonSchema, type Tool, type ToolResult } from 'clyops-tools';
9
+ import type { IncomingMessage, ServerResponse } from 'node:http';
10
+
11
+ export interface McpOptions {
12
+ /** Server name shown to clients (e.g. the tools directory's name). */
13
+ name: string;
14
+ version?: string;
15
+ /** Instructions for the agent, e.g. the tools directory's description. */
16
+ instructions?: string;
17
+ tools: Tool[];
18
+ /** Working directory tools run in (default: the server's). */
19
+ cwd?: string;
20
+ /** Kill a tool after this long (0: never). */
21
+ timeoutMs?: number;
22
+ /** Path-valued arguments must resolve inside these directories. */
23
+ within?: string[];
24
+ /** Keep at most this many bytes of a tool's stdout and stderr (0: all). */
25
+ maxOutput?: number;
26
+ /** Called after every run, for an audit log. */
27
+ audit?: (entry: Omit<AuditEntry, 'time'>) => void;
28
+ }
29
+
30
+ /** The MCP tool name for a clyops tool: its words joined by `_` (`media_to-pcm`). */
31
+ export function toolName(tool: Tool): string {
32
+ return tool.words.join('_').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
33
+ }
34
+
35
+ /** A tool's declared binary stdout type, if any (spec section 1.3). */
36
+ function binaryStdout(tool: Tool): string | undefined {
37
+ const type = tool.schema.stdout?.contentType;
38
+ return type && !isTextType(type) ? type.split(';')[0].trim() : undefined;
39
+ }
40
+
41
+ /**
42
+ * What the agent reads back from a run: stdout (an image, audio or a blob for
43
+ * declared binary output), and on failure the exit status and stderr.
44
+ */
45
+ function toResult(tool: Tool, result: ToolResult): CallToolResult {
46
+ const content: CallToolResult['content'] = [];
47
+ const binary = binaryStdout(tool);
48
+ if (binary && result.stdoutBuffer?.length) {
49
+ const data = result.stdoutBuffer.toString('base64');
50
+ if (binary.startsWith('image/')) content.push({ type: 'image', data, mimeType: binary });
51
+ else if (binary.startsWith('audio/')) content.push({ type: 'audio', data, mimeType: binary });
52
+ else content.push({ type: 'resource', resource: { uri: `clyops://${toolName(tool)}/stdout`, mimeType: binary, blob: data } });
53
+ }
54
+ if (result.stdout) content.push({ type: 'text', text: result.stdout });
55
+ if (!result.ok) {
56
+ const why = result.timedOut ? 'timed out' : result.signal ? `was killed (${result.signal})` : `exited with status ${result.exitCode}`;
57
+ // All of stderr: a tool's error line often comes before its usage text.
58
+ content.push({ type: 'text', text: [`${tool.words.join(' ')} ${why}.`, ...tail(result.stderr, Infinity)].join('\n') });
59
+ }
60
+ if (!content.length) content.push({ type: 'text', text: '(no output)' });
61
+ const json = result.json;
62
+ return {
63
+ content,
64
+ isError: !result.ok,
65
+ ...(json && typeof json === 'object' && !Array.isArray(json) ? { structuredContent: json as Record<string, unknown> } : {}),
66
+ };
67
+ }
68
+
69
+ // Each tool's input schema and compiled validator, built once per tool object.
70
+ // A tool that declares stdin takes it as one more argument, `stdin`.
71
+ const validator = new AjvJsonSchemaValidator();
72
+ const prepared = new WeakMap<Tool, { inputSchema: JsonSchema; validate: JsonSchemaValidator<Record<string, unknown>> }>();
73
+ function prepare(tool: Tool) {
74
+ let entry = prepared.get(tool);
75
+ if (!entry) {
76
+ const inputSchema = toJsonSchema(tool.schema);
77
+ const stdin = tool.schema.stdin;
78
+ const properties = inputSchema.properties as Record<string, JsonSchema>;
79
+ if (stdin && !('stdin' in properties)) {
80
+ const text = isTextType(stdin.contentType);
81
+ const what = [stdin.description, stdin.contentType && `(${stdin.contentType})`].filter(Boolean).join(' ');
82
+ properties.stdin = { type: 'string', description: `Standard input${what ? `: ${what}` : ''}${text ? '' : ', base64-encoded'}`, ...(text ? {} : { contentEncoding: 'base64' }) };
83
+ }
84
+ entry = { inputSchema, validate: validator.getValidator<Record<string, unknown>>(inputSchema as never) };
85
+ prepared.set(tool, entry);
86
+ }
87
+ return entry;
88
+ }
89
+
90
+ /** MCP tool annotations from the tool's declared effects (spec section 1.3). */
91
+ function annotations(tool: Tool) {
92
+ const effects = tool.schema.effects ?? [];
93
+ const out: Record<string, boolean> = {};
94
+ if (effects.includes('read-only')) out.readOnlyHint = true;
95
+ if (effects.includes('destructive')) out.destructiveHint = true;
96
+ if (effects.includes('idempotent')) out.idempotentHint = true;
97
+ if (effects.includes('network')) out.openWorldHint = true;
98
+ return Object.keys(out).length ? { annotations: out } : {};
99
+ }
100
+
101
+ /**
102
+ * The SDK server for `opts`. It reads `opts.tools` on every request, so
103
+ * replacing it (then calling `server.sendToolListChanged()` on a connected
104
+ * server) updates the tools without a restart.
105
+ */
106
+ export function createMcpServer(opts: McpOptions): Server {
107
+ const server = new Server({ name: opts.name, version: opts.version ?? '0.0.0' }, { capabilities: { tools: { listChanged: true } }, instructions: opts.instructions });
108
+
109
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
110
+ tools: opts.tools.map((tool) => ({
111
+ name: toolName(tool),
112
+ title: tool.words.join(' '),
113
+ description: [tool.schema.description, tool.schema.epilog].filter(Boolean).join('\n\n'),
114
+ inputSchema: prepare(tool).inputSchema as { type: 'object'; [key: string]: unknown },
115
+ ...annotations(tool),
116
+ })),
117
+ }));
118
+
119
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
120
+ const tool = opts.tools.find((t) => toolName(t) === request.params.name);
121
+ if (!tool) return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
122
+ const checked = prepare(tool).validate(request.params.arguments ?? {});
123
+ if (!checked.valid) return { content: [{ type: 'text', text: `Invalid arguments: ${checked.errorMessage}` }], isError: true };
124
+ const { stdin, ...input } = checked.data;
125
+ const declared = tool.schema.stdin;
126
+ const stdinData = declared && typeof stdin === 'string' ? (isTextType(declared.contentType) ? stdin : Buffer.from(stdin, 'base64')) : undefined;
127
+ let result: ToolResult;
128
+ try {
129
+ result = await runTool(tool, declared ? input : checked.data, {
130
+ cwd: opts.cwd, timeoutMs: opts.timeoutMs, signal: extra.signal, within: opts.within, maxOutput: opts.maxOutput,
131
+ stdin: stdinData, stdout: binaryStdout(tool) ? 'buffer' : 'text',
132
+ });
133
+ } catch (err) {
134
+ if (err instanceof InputError) return { content: [{ type: 'text', text: `Invalid arguments: ${err.message}` }], isError: true };
135
+ throw err;
136
+ }
137
+ opts.audit?.({
138
+ tool: tool.words.join(' '), command: result.command, exitCode: result.exitCode, signal: result.signal,
139
+ timedOut: result.timedOut, durationMs: result.durationMs, via: 'mcp',
140
+ });
141
+ return toResult(tool, result);
142
+ });
143
+
144
+ return server;
145
+ }
146
+
147
+ /**
148
+ * A request handler serving MCP over streamable HTTP, statelessly: each
149
+ * request gets its own server and transport. Mount it on POST (and GET/DELETE,
150
+ * which it answers with 405) at a path such as `/mcp`, after a JSON body parser.
151
+ * Pass a function to serve whatever it returns for each request (given the request, e.g. to serve per-caller tools).
152
+ */
153
+ export function mcpHttpHandler<R extends IncomingMessage & { body?: unknown }>(opts: McpOptions | ((req: R) => McpOptions)) {
154
+ return async (req: R, res: ServerResponse): Promise<void> => {
155
+ const server = createMcpServer(typeof opts === 'function' ? opts(req) : opts);
156
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
157
+ res.on('close', () => {
158
+ void transport.close();
159
+ void server.close();
160
+ });
161
+ await server.connect(transport);
162
+ await transport.handleRequest(req, res, req.body);
163
+ };
164
+ }