clyops-mcp 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +110 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +58 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/server.d.ts +40 -0
- package/dist/server.js +146 -0
- package/package.json +50 -3
- package/src/cli.ts +61 -0
- package/src/index.ts +2 -0
- package/src/server.ts +166 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan VerWeire
|
|
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,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# clyops-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
pnpm add --global 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.
|
|
107
|
+
|
|
108
|
+
`--positionals-order first|last` controls generated argument order (default:
|
|
109
|
+
`first`). An option terminator is only emitted for positional values starting
|
|
110
|
+
with `-`, which are always placed after the options. The Node API exposes this
|
|
111
|
+
as `positionalsOrder`.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
cli.opt('CLYOPS_MCP_POSITIONALS_ORDER', 'positionals-order', '', 'first', 'Positionals before or after options', 'Tools', 'choice:first,last');
|
|
24
|
+
const args = cli.run();
|
|
25
|
+
// stdout is the MCP channel; clyops logs to stderr.
|
|
26
|
+
const onError = (cmd, err) => warn('%s%s', cmd ? `skipping ${cmd.words.join(' ')}: ` : '', err.message);
|
|
27
|
+
const filter = { allow: args.CLYOPS_MCP_ALLOW, deny: args.CLYOPS_MCP_DENY, readOnly: args.CLYOPS_MCP_READ_ONLY };
|
|
28
|
+
const opts = { name: args.CLYOPS_MCP_NAME ?? undefined, filter, onError };
|
|
29
|
+
const within = args.CLYOPS_MCP_PATHS_WITHIN;
|
|
30
|
+
const watcher = args.CLYOPS_MCP_WATCH
|
|
31
|
+
? await watchTools(args.CLYOPS_MCP_ROOT, {
|
|
32
|
+
...opts,
|
|
33
|
+
// The server reads mcp.tools on each request; tell the agent the list changed.
|
|
34
|
+
onChange: ({ tools }) => {
|
|
35
|
+
mcp.tools = tools;
|
|
36
|
+
void server.sendToolListChanged();
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
: undefined;
|
|
40
|
+
const { tree, tools } = watcher ? watcher.current() : await loadTools(args.CLYOPS_MCP_ROOT, opts);
|
|
41
|
+
const mcp = {
|
|
42
|
+
name: tree.name,
|
|
43
|
+
version,
|
|
44
|
+
instructions: tree.description || undefined,
|
|
45
|
+
tools,
|
|
46
|
+
cwd: args.CLYOPS_MCP_CWD ?? undefined,
|
|
47
|
+
positionalsOrder: args.CLYOPS_MCP_POSITIONALS_ORDER,
|
|
48
|
+
timeoutMs: args.CLYOPS_MCP_TIMEOUT * 1000,
|
|
49
|
+
within: within.length ? within : undefined,
|
|
50
|
+
maxOutput: args.CLYOPS_MCP_MAX_OUTPUT,
|
|
51
|
+
audit: args.CLYOPS_MCP_AUDIT ? auditLog(args.CLYOPS_MCP_AUDIT) : undefined,
|
|
52
|
+
};
|
|
53
|
+
const server = createMcpServer(mcp);
|
|
54
|
+
// The watcher would keep the process alive after the agent disconnects, and
|
|
55
|
+
// the stdio transport doesn't close by itself when stdin ends.
|
|
56
|
+
server.onclose = () => watcher?.close();
|
|
57
|
+
process.stdin.on('end', () => void server.close());
|
|
58
|
+
await server.connect(new StdioServerTransport());
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './server.js';
|
package/dist/index.js
ADDED
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
/** Positionals before or after options (default: first). */
|
|
6
|
+
positionalsOrder?: 'first' | 'last';
|
|
7
|
+
/** Server name shown to clients (e.g. the tools directory's name). */
|
|
8
|
+
name: string;
|
|
9
|
+
version?: string;
|
|
10
|
+
/** Instructions for the agent, e.g. the tools directory's description. */
|
|
11
|
+
instructions?: string;
|
|
12
|
+
tools: Tool[];
|
|
13
|
+
/** Working directory tools run in (default: the server's). */
|
|
14
|
+
cwd?: string;
|
|
15
|
+
/** Kill a tool after this long (0: never). */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** Path-valued arguments must resolve inside these directories. */
|
|
18
|
+
within?: string[];
|
|
19
|
+
/** Keep at most this many bytes of a tool's stdout and stderr (0: all). */
|
|
20
|
+
maxOutput?: number;
|
|
21
|
+
/** Called after every run, for an audit log. */
|
|
22
|
+
audit?: (entry: Omit<AuditEntry, 'time'>) => void;
|
|
23
|
+
}
|
|
24
|
+
/** The MCP tool name for a clyops tool: its words joined by `_` (`media_to-pcm`). */
|
|
25
|
+
export declare function toolName(tool: Tool): string;
|
|
26
|
+
/**
|
|
27
|
+
* The SDK server for `opts`. It reads `opts.tools` on every request, so
|
|
28
|
+
* replacing it (then calling `server.sendToolListChanged()` on a connected
|
|
29
|
+
* server) updates the tools without a restart.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createMcpServer(opts: McpOptions): Server;
|
|
32
|
+
/**
|
|
33
|
+
* A request handler serving MCP over streamable HTTP, statelessly: each
|
|
34
|
+
* request gets its own server and transport. Mount it on POST (and GET/DELETE,
|
|
35
|
+
* which it answers with 405) at a path such as `/mcp`, after a JSON body parser.
|
|
36
|
+
* Pass a function to serve whatever it returns for each request (given the request, e.g. to serve per-caller tools).
|
|
37
|
+
*/
|
|
38
|
+
export declare function mcpHttpHandler<R extends IncomingMessage & {
|
|
39
|
+
body?: unknown;
|
|
40
|
+
}>(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, positionalsOrder: opts.positionalsOrder, 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,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clyops-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.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
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
40
|
+
"clyops": "0.3.0",
|
|
41
|
+
"clyops-tools": "0.3.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"typescript": "^5.6.0",
|
|
46
|
+
"express": "^5.2.1"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
50
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
51
|
+
"test": "node --test test/*.test.mjs"
|
|
52
|
+
}
|
|
6
53
|
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
cli.opt('CLYOPS_MCP_POSITIONALS_ORDER', 'positionals-order', '', 'first', 'Positionals before or after options', 'Tools', 'choice:first,last');
|
|
26
|
+
const args = cli.run();
|
|
27
|
+
|
|
28
|
+
// stdout is the MCP channel; clyops logs to stderr.
|
|
29
|
+
const onError = (cmd: { words: string[] } | null, err: Error) => warn('%s%s', cmd ? `skipping ${cmd.words.join(' ')}: ` : '', err.message);
|
|
30
|
+
const filter = { allow: args.CLYOPS_MCP_ALLOW as string[], deny: args.CLYOPS_MCP_DENY as string[], readOnly: args.CLYOPS_MCP_READ_ONLY as boolean };
|
|
31
|
+
const opts = { name: (args.CLYOPS_MCP_NAME as string | null) ?? undefined, filter, onError };
|
|
32
|
+
const within = args.CLYOPS_MCP_PATHS_WITHIN as string[];
|
|
33
|
+
const watcher = args.CLYOPS_MCP_WATCH
|
|
34
|
+
? await watchTools(args.CLYOPS_MCP_ROOT as string, {
|
|
35
|
+
...opts,
|
|
36
|
+
// The server reads mcp.tools on each request; tell the agent the list changed.
|
|
37
|
+
onChange: ({ tools }) => {
|
|
38
|
+
mcp.tools = tools;
|
|
39
|
+
void server.sendToolListChanged();
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
: undefined;
|
|
43
|
+
const { tree, tools } = watcher ? watcher.current() : await loadTools(args.CLYOPS_MCP_ROOT as string, opts);
|
|
44
|
+
const mcp = {
|
|
45
|
+
name: tree.name,
|
|
46
|
+
version,
|
|
47
|
+
instructions: tree.description || undefined,
|
|
48
|
+
tools,
|
|
49
|
+
cwd: (args.CLYOPS_MCP_CWD as string | null) ?? undefined,
|
|
50
|
+
positionalsOrder: args.CLYOPS_MCP_POSITIONALS_ORDER as 'first' | 'last',
|
|
51
|
+
timeoutMs: (args.CLYOPS_MCP_TIMEOUT as number) * 1000,
|
|
52
|
+
within: within.length ? within : undefined,
|
|
53
|
+
maxOutput: args.CLYOPS_MCP_MAX_OUTPUT as number,
|
|
54
|
+
audit: args.CLYOPS_MCP_AUDIT ? auditLog(args.CLYOPS_MCP_AUDIT as string) : undefined,
|
|
55
|
+
};
|
|
56
|
+
const server = createMcpServer(mcp);
|
|
57
|
+
// The watcher would keep the process alive after the agent disconnects, and
|
|
58
|
+
// the stdio transport doesn't close by itself when stdin ends.
|
|
59
|
+
server.onclose = () => watcher?.close();
|
|
60
|
+
process.stdin.on('end', () => void server.close());
|
|
61
|
+
await server.connect(new StdioServerTransport());
|
package/src/index.ts
ADDED
package/src/server.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
/** Positionals before or after options (default: first). */
|
|
13
|
+
positionalsOrder?: 'first' | 'last';
|
|
14
|
+
/** Server name shown to clients (e.g. the tools directory's name). */
|
|
15
|
+
name: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
/** Instructions for the agent, e.g. the tools directory's description. */
|
|
18
|
+
instructions?: string;
|
|
19
|
+
tools: Tool[];
|
|
20
|
+
/** Working directory tools run in (default: the server's). */
|
|
21
|
+
cwd?: string;
|
|
22
|
+
/** Kill a tool after this long (0: never). */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** Path-valued arguments must resolve inside these directories. */
|
|
25
|
+
within?: string[];
|
|
26
|
+
/** Keep at most this many bytes of a tool's stdout and stderr (0: all). */
|
|
27
|
+
maxOutput?: number;
|
|
28
|
+
/** Called after every run, for an audit log. */
|
|
29
|
+
audit?: (entry: Omit<AuditEntry, 'time'>) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The MCP tool name for a clyops tool: its words joined by `_` (`media_to-pcm`). */
|
|
33
|
+
export function toolName(tool: Tool): string {
|
|
34
|
+
return tool.words.join('_').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A tool's declared binary stdout type, if any (spec section 1.3). */
|
|
38
|
+
function binaryStdout(tool: Tool): string | undefined {
|
|
39
|
+
const type = tool.schema.stdout?.contentType;
|
|
40
|
+
return type && !isTextType(type) ? type.split(';')[0].trim() : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the agent reads back from a run: stdout (an image, audio or a blob for
|
|
45
|
+
* declared binary output), and on failure the exit status and stderr.
|
|
46
|
+
*/
|
|
47
|
+
function toResult(tool: Tool, result: ToolResult): CallToolResult {
|
|
48
|
+
const content: CallToolResult['content'] = [];
|
|
49
|
+
const binary = binaryStdout(tool);
|
|
50
|
+
if (binary && result.stdoutBuffer?.length) {
|
|
51
|
+
const data = result.stdoutBuffer.toString('base64');
|
|
52
|
+
if (binary.startsWith('image/')) content.push({ type: 'image', data, mimeType: binary });
|
|
53
|
+
else if (binary.startsWith('audio/')) content.push({ type: 'audio', data, mimeType: binary });
|
|
54
|
+
else content.push({ type: 'resource', resource: { uri: `clyops://${toolName(tool)}/stdout`, mimeType: binary, blob: data } });
|
|
55
|
+
}
|
|
56
|
+
if (result.stdout) content.push({ type: 'text', text: result.stdout });
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
const why = result.timedOut ? 'timed out' : result.signal ? `was killed (${result.signal})` : `exited with status ${result.exitCode}`;
|
|
59
|
+
// All of stderr: a tool's error line often comes before its usage text.
|
|
60
|
+
content.push({ type: 'text', text: [`${tool.words.join(' ')} ${why}.`, ...tail(result.stderr, Infinity)].join('\n') });
|
|
61
|
+
}
|
|
62
|
+
if (!content.length) content.push({ type: 'text', text: '(no output)' });
|
|
63
|
+
const json = result.json;
|
|
64
|
+
return {
|
|
65
|
+
content,
|
|
66
|
+
isError: !result.ok,
|
|
67
|
+
...(json && typeof json === 'object' && !Array.isArray(json) ? { structuredContent: json as Record<string, unknown> } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Each tool's input schema and compiled validator, built once per tool object.
|
|
72
|
+
// A tool that declares stdin takes it as one more argument, `stdin`.
|
|
73
|
+
const validator = new AjvJsonSchemaValidator();
|
|
74
|
+
const prepared = new WeakMap<Tool, { inputSchema: JsonSchema; validate: JsonSchemaValidator<Record<string, unknown>> }>();
|
|
75
|
+
function prepare(tool: Tool) {
|
|
76
|
+
let entry = prepared.get(tool);
|
|
77
|
+
if (!entry) {
|
|
78
|
+
const inputSchema = toJsonSchema(tool.schema);
|
|
79
|
+
const stdin = tool.schema.stdin;
|
|
80
|
+
const properties = inputSchema.properties as Record<string, JsonSchema>;
|
|
81
|
+
if (stdin && !('stdin' in properties)) {
|
|
82
|
+
const text = isTextType(stdin.contentType);
|
|
83
|
+
const what = [stdin.description, stdin.contentType && `(${stdin.contentType})`].filter(Boolean).join(' ');
|
|
84
|
+
properties.stdin = { type: 'string', description: `Standard input${what ? `: ${what}` : ''}${text ? '' : ', base64-encoded'}`, ...(text ? {} : { contentEncoding: 'base64' }) };
|
|
85
|
+
}
|
|
86
|
+
entry = { inputSchema, validate: validator.getValidator<Record<string, unknown>>(inputSchema as never) };
|
|
87
|
+
prepared.set(tool, entry);
|
|
88
|
+
}
|
|
89
|
+
return entry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** MCP tool annotations from the tool's declared effects (spec section 1.3). */
|
|
93
|
+
function annotations(tool: Tool) {
|
|
94
|
+
const effects = tool.schema.effects ?? [];
|
|
95
|
+
const out: Record<string, boolean> = {};
|
|
96
|
+
if (effects.includes('read-only')) out.readOnlyHint = true;
|
|
97
|
+
if (effects.includes('destructive')) out.destructiveHint = true;
|
|
98
|
+
if (effects.includes('idempotent')) out.idempotentHint = true;
|
|
99
|
+
if (effects.includes('network')) out.openWorldHint = true;
|
|
100
|
+
return Object.keys(out).length ? { annotations: out } : {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The SDK server for `opts`. It reads `opts.tools` on every request, so
|
|
105
|
+
* replacing it (then calling `server.sendToolListChanged()` on a connected
|
|
106
|
+
* server) updates the tools without a restart.
|
|
107
|
+
*/
|
|
108
|
+
export function createMcpServer(opts: McpOptions): Server {
|
|
109
|
+
const server = new Server({ name: opts.name, version: opts.version ?? '0.0.0' }, { capabilities: { tools: { listChanged: true } }, instructions: opts.instructions });
|
|
110
|
+
|
|
111
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
112
|
+
tools: opts.tools.map((tool) => ({
|
|
113
|
+
name: toolName(tool),
|
|
114
|
+
title: tool.words.join(' '),
|
|
115
|
+
description: [tool.schema.description, tool.schema.epilog].filter(Boolean).join('\n\n'),
|
|
116
|
+
inputSchema: prepare(tool).inputSchema as { type: 'object'; [key: string]: unknown },
|
|
117
|
+
...annotations(tool),
|
|
118
|
+
})),
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
122
|
+
const tool = opts.tools.find((t) => toolName(t) === request.params.name);
|
|
123
|
+
if (!tool) return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
|
124
|
+
const checked = prepare(tool).validate(request.params.arguments ?? {});
|
|
125
|
+
if (!checked.valid) return { content: [{ type: 'text', text: `Invalid arguments: ${checked.errorMessage}` }], isError: true };
|
|
126
|
+
const { stdin, ...input } = checked.data;
|
|
127
|
+
const declared = tool.schema.stdin;
|
|
128
|
+
const stdinData = declared && typeof stdin === 'string' ? (isTextType(declared.contentType) ? stdin : Buffer.from(stdin, 'base64')) : undefined;
|
|
129
|
+
let result: ToolResult;
|
|
130
|
+
try {
|
|
131
|
+
result = await runTool(tool, declared ? input : checked.data, {
|
|
132
|
+
cwd: opts.cwd, timeoutMs: opts.timeoutMs, positionalsOrder: opts.positionalsOrder, signal: extra.signal, within: opts.within, maxOutput: opts.maxOutput,
|
|
133
|
+
stdin: stdinData, stdout: binaryStdout(tool) ? 'buffer' : 'text',
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err instanceof InputError) return { content: [{ type: 'text', text: `Invalid arguments: ${err.message}` }], isError: true };
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
opts.audit?.({
|
|
140
|
+
tool: tool.words.join(' '), command: result.command, exitCode: result.exitCode, signal: result.signal,
|
|
141
|
+
timedOut: result.timedOut, durationMs: result.durationMs, via: 'mcp',
|
|
142
|
+
});
|
|
143
|
+
return toResult(tool, result);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return server;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A request handler serving MCP over streamable HTTP, statelessly: each
|
|
151
|
+
* request gets its own server and transport. Mount it on POST (and GET/DELETE,
|
|
152
|
+
* which it answers with 405) at a path such as `/mcp`, after a JSON body parser.
|
|
153
|
+
* Pass a function to serve whatever it returns for each request (given the request, e.g. to serve per-caller tools).
|
|
154
|
+
*/
|
|
155
|
+
export function mcpHttpHandler<R extends IncomingMessage & { body?: unknown }>(opts: McpOptions | ((req: R) => McpOptions)) {
|
|
156
|
+
return async (req: R, res: ServerResponse): Promise<void> => {
|
|
157
|
+
const server = createMcpServer(typeof opts === 'function' ? opts(req) : opts);
|
|
158
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
159
|
+
res.on('close', () => {
|
|
160
|
+
void transport.close();
|
|
161
|
+
void server.close();
|
|
162
|
+
});
|
|
163
|
+
await server.connect(transport);
|
|
164
|
+
await transport.handleRequest(req, res, req.body);
|
|
165
|
+
};
|
|
166
|
+
}
|