@softov/ahpc 0.1.0 → 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/README.md +69 -1
- package/dist/src/ahp/channels.js +10 -3
- package/dist/src/ahp/live.js +63 -6
- package/dist/src/ahp/publish.js +31 -6
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +100 -98
- package/dist/src/flags.js +7 -0
- package/dist/src/main.js +14 -2
- package/dist/src/mcp/http.d.ts +34 -0
- package/dist/src/mcp/http.js +247 -0
- package/dist/src/mcp/serve.d.ts +96 -0
- package/dist/src/mcp/serve.js +169 -0
- package/dist/src/mcp/stdio.d.ts +14 -0
- package/dist/src/mcp/stdio.js +64 -0
- package/dist/src/mcp/tools.d.ts +68 -0
- package/dist/src/mcp/tools.js +765 -0
- package/dist/src/tui.d.ts +1 -1
- package/dist/src/tui.js +1 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +38 -0
- package/dist/src/wait.d.ts +56 -0
- package/dist/src/wait.js +160 -0
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { HostConnection } from '../ahp/connection.js';
|
|
2
|
+
export interface ServeOptions {
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
/**
|
|
8
|
+
* A bearer token every request must carry, if any.
|
|
9
|
+
*
|
|
10
|
+
* Absent means anybody who can reach the port can drive every session on the
|
|
11
|
+
* host, which is why `--serve-host` defaults to the loopback address. A
|
|
12
|
+
* token is what makes binding anywhere else defensible.
|
|
13
|
+
*/
|
|
14
|
+
token?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Browser origins allowed to reach this, beyond its own.
|
|
17
|
+
*
|
|
18
|
+
* Empty is the safe default and the usual answer: a shell script, a webhook
|
|
19
|
+
* and an MCP client send no `Origin` at all, so nothing legitimate is turned
|
|
20
|
+
* away by allowing none. This is for a page somebody serves themselves.
|
|
21
|
+
*/
|
|
22
|
+
origins?: readonly string[];
|
|
23
|
+
/** The opt-in tool groups to serve, beyond the core table. */
|
|
24
|
+
groups?: readonly string[];
|
|
25
|
+
onProblem?(said: string): void;
|
|
26
|
+
}
|
|
27
|
+
/** What this is listening on, and how to stop it. */
|
|
28
|
+
export interface Serving {
|
|
29
|
+
host: string;
|
|
30
|
+
port: number;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/** Start listening. Answers once the socket is up. */
|
|
34
|
+
export declare function serve(host: HostConnection, options: ServeOptions): Promise<Serving>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The same tools over a socket, twice.
|
|
3
|
+
*
|
|
4
|
+
* `POST /mcp` is MCP's streamable HTTP transport, which for a tools-only
|
|
5
|
+
* server is a JSON-RPC request in and a JSON-RPC response out. `POST
|
|
6
|
+
* /api/<tool>` is the same tool with the arguments as the body and the answer
|
|
7
|
+
* as the body, for everything that is not an MCP client - a shell script, a
|
|
8
|
+
* webhook, a program in another language. `GET /api` lists what there is.
|
|
9
|
+
*
|
|
10
|
+
* One process, one connection to the host, however many callers. That is the
|
|
11
|
+
* difference from `stdio`, and the reason both exist: stdio is owned by the
|
|
12
|
+
* client that launched it, this is shared and outlives any of them.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { SPOKEN, answer, call, listing } from './serve.js';
|
|
16
|
+
/** How much of a request body is read before it is refused, in bytes. */
|
|
17
|
+
const LIMIT = 1_000_000;
|
|
18
|
+
const body = async (request) => {
|
|
19
|
+
let read = '';
|
|
20
|
+
for await (const chunk of request) {
|
|
21
|
+
read += String(chunk);
|
|
22
|
+
if (read.length > LIMIT)
|
|
23
|
+
throw new Error('That request is too big.');
|
|
24
|
+
}
|
|
25
|
+
return read;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Answer a request with a stream rather than one object.
|
|
29
|
+
*
|
|
30
|
+
* The transport allows either, and this is the branch that lets anything be
|
|
31
|
+
* said before the result: notifications go out as they happen and the response
|
|
32
|
+
* is the last event, after which the stream closes. Only opened where the
|
|
33
|
+
* caller asked for progress, because a stream is worse for everybody else -
|
|
34
|
+
* more to parse, and a connection held open for a request that answers at once.
|
|
35
|
+
*/
|
|
36
|
+
const stream = (response) => {
|
|
37
|
+
response.writeHead(200, {
|
|
38
|
+
'content-type': 'text/event-stream',
|
|
39
|
+
'cache-control': 'no-cache, no-transform',
|
|
40
|
+
connection: 'keep-alive',
|
|
41
|
+
});
|
|
42
|
+
const event = (value) => { response.write(`data: ${JSON.stringify(value)}\n\n`); };
|
|
43
|
+
return {
|
|
44
|
+
event,
|
|
45
|
+
end: (value) => { event(value); response.end(); },
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Whether this request asked to be told what is happening while it waits.
|
|
50
|
+
*
|
|
51
|
+
* A `progressToken` in `_meta`, which is the only way a caller asks. Read here
|
|
52
|
+
* rather than inside `answer`, because the decision it drives - a stream or an
|
|
53
|
+
* object - has to be made before a single byte of the response is written.
|
|
54
|
+
*/
|
|
55
|
+
const wantsProgress = (message) => {
|
|
56
|
+
const params = message.params;
|
|
57
|
+
const token = params?._meta?.progressToken;
|
|
58
|
+
return typeof token === 'string' || typeof token === 'number';
|
|
59
|
+
};
|
|
60
|
+
const send = (response, code, value) => {
|
|
61
|
+
const text = JSON.stringify(value);
|
|
62
|
+
response.writeHead(code, {
|
|
63
|
+
'content-type': 'application/json',
|
|
64
|
+
'content-length': String(Buffer.byteLength(text)),
|
|
65
|
+
});
|
|
66
|
+
response.end(text);
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Whether a request carries the token, where one was set.
|
|
70
|
+
*
|
|
71
|
+
* `Authorization: Bearer <token>`, which is what MCP's own HTTP transport
|
|
72
|
+
* says, and what every client that speaks it already sends.
|
|
73
|
+
*/
|
|
74
|
+
const allowed = (request, token) => {
|
|
75
|
+
if (token === undefined)
|
|
76
|
+
return true;
|
|
77
|
+
const said = request.headers.authorization;
|
|
78
|
+
return typeof said === 'string' && said.trim() === `Bearer ${token}`;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Whether a browser may talk to this.
|
|
82
|
+
*
|
|
83
|
+
* The transport says a server **MUST** validate `Origin` to stop DNS
|
|
84
|
+
* rebinding, and the reason it says so is that binding to loopback is not the
|
|
85
|
+
* protection it looks like: a page on any website can POST to
|
|
86
|
+
* `http://127.0.0.1:7431` from inside the browser of the person running this,
|
|
87
|
+
* and the request arrives from their own machine looking exactly like theirs.
|
|
88
|
+
* Without this, opening a tab would be enough to drive every session on the
|
|
89
|
+
* host.
|
|
90
|
+
*
|
|
91
|
+
* No header at all is allowed. A browser always sends one on a request like
|
|
92
|
+
* these; a program does not, and refusing those would refuse every real
|
|
93
|
+
* caller to guard against a thing that cannot happen.
|
|
94
|
+
*/
|
|
95
|
+
const sameOrigin = (request, url, extra) => {
|
|
96
|
+
const said = request.headers.origin;
|
|
97
|
+
if (typeof said !== 'string' || said === '')
|
|
98
|
+
return true;
|
|
99
|
+
if (extra.includes(said))
|
|
100
|
+
return true;
|
|
101
|
+
try {
|
|
102
|
+
const from = new URL(said);
|
|
103
|
+
// The host and port this request came in on, whatever name was used to
|
|
104
|
+
// reach it - `localhost` and `127.0.0.1` are the same server and a person
|
|
105
|
+
// typing either should not be told no.
|
|
106
|
+
return from.host === url.host;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Whether this can speak the version the client says it is using.
|
|
114
|
+
*
|
|
115
|
+
* The transport says an invalid or unsupported `MCP-Protocol-Version` **MUST**
|
|
116
|
+
* be a 400. Absent is not unsupported: the same paragraph says to assume
|
|
117
|
+
* `2025-03-26` where there is no header, which is a client from before it
|
|
118
|
+
* existed.
|
|
119
|
+
*/
|
|
120
|
+
const speakable = (request) => {
|
|
121
|
+
const said = request.headers['mcp-protocol-version'];
|
|
122
|
+
if (said === undefined)
|
|
123
|
+
return true;
|
|
124
|
+
return typeof said === 'string' && SPOKEN.includes(said);
|
|
125
|
+
};
|
|
126
|
+
/** Start listening. Answers once the socket is up. */
|
|
127
|
+
export async function serve(host, options) {
|
|
128
|
+
const server = createServer((request, response) => {
|
|
129
|
+
void (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
|
132
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
133
|
+
if (!sameOrigin(request, url, options.origins ?? [])) {
|
|
134
|
+
// 403 rather than 401: a token would not make this request
|
|
135
|
+
// acceptable, so inviting one would be the wrong thing to say.
|
|
136
|
+
send(response, 403, { error: `This server does not serve requests from ${String(request.headers.origin)}.` });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!speakable(request)) {
|
|
140
|
+
send(response, 400, {
|
|
141
|
+
error: `This server does not speak MCP ${String(request.headers['mcp-protocol-version'])}. It speaks ${SPOKEN.join(', ')}.`,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!allowed(request, options.token)) {
|
|
146
|
+
send(response, 401, { error: 'This server needs a bearer token.' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// What is here, for somebody who has just started it and wants to know
|
|
150
|
+
// what to call. Every tool with its schema, which is also what an MCP
|
|
151
|
+
// client gets from `tools/list`.
|
|
152
|
+
if (request.method === 'GET' && (path === '/api' || path === '/')) {
|
|
153
|
+
send(response, 200, listing(options.groups));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (path === '/mcp') {
|
|
157
|
+
if (request.method !== 'POST') {
|
|
158
|
+
// No SSE stream: this server sends nothing a client did not ask
|
|
159
|
+
// for, so there is nothing for a GET to hold open.
|
|
160
|
+
send(response, 405, { error: 'POST a JSON-RPC message here.' });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let message;
|
|
164
|
+
try {
|
|
165
|
+
message = JSON.parse(await body(request));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
send(response, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'That is not JSON.' } });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (wantsProgress(message)) {
|
|
172
|
+
const open = stream(response);
|
|
173
|
+
const reply = await answer(host, message, {
|
|
174
|
+
...options,
|
|
175
|
+
notify: (notification) => open.event({ jsonrpc: '2.0', ...notification }),
|
|
176
|
+
});
|
|
177
|
+
// A notification that also carried a progress token has nothing to
|
|
178
|
+
// end the stream with, so it is closed rather than left open on a
|
|
179
|
+
// response that is never coming.
|
|
180
|
+
if (reply === undefined) {
|
|
181
|
+
response.end();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
open.end(reply);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const reply = await answer(host, message, options);
|
|
188
|
+
// A notification is answered with 202 and no body, which is what the
|
|
189
|
+
// transport says and what a client waiting on one would hang over.
|
|
190
|
+
if (reply === undefined) {
|
|
191
|
+
response.writeHead(202);
|
|
192
|
+
response.end();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
send(response, 200, reply);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (path.startsWith('/api/')) {
|
|
199
|
+
if (request.method !== 'POST') {
|
|
200
|
+
send(response, 405, { error: 'POST to call a tool.' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const name = path.slice('/api/'.length);
|
|
204
|
+
const raw = await body(request);
|
|
205
|
+
let input = {};
|
|
206
|
+
if (raw.trim() !== '') {
|
|
207
|
+
try {
|
|
208
|
+
input = JSON.parse(raw);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
send(response, 400, { error: 'That is not JSON.' });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const result = await call(host, name, input, undefined, options.groups);
|
|
216
|
+
// Shaped for a program rather than for a model: the answer itself,
|
|
217
|
+
// or the refusal as an error, without MCP's content envelope around
|
|
218
|
+
// it. A caller that wants the envelope has `/mcp`.
|
|
219
|
+
if (result.isError === true) {
|
|
220
|
+
send(response, 400, { error: result.content?.[0]?.text ?? 'That did not work.' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
send(response, 200, result.structuredContent ?? {});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
send(response, 404, { error: `Nothing at ${path}. Try GET /api.` });
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
options.onProblem?.(error instanceof Error ? error.message : String(error));
|
|
230
|
+
if (!response.headersSent)
|
|
231
|
+
send(response, 500, { error: 'Something went wrong here.' });
|
|
232
|
+
else
|
|
233
|
+
response.end();
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
});
|
|
237
|
+
await new Promise((up, fail) => {
|
|
238
|
+
server.once('error', fail);
|
|
239
|
+
server.listen(options.port, options.host, () => { server.off('error', fail); up(); });
|
|
240
|
+
});
|
|
241
|
+
const found = server.address();
|
|
242
|
+
return {
|
|
243
|
+
host: typeof found === 'object' && found !== null ? found.address : options.host,
|
|
244
|
+
port: typeof found === 'object' && found !== null ? found.port : options.port,
|
|
245
|
+
close: () => new Promise((done) => { server.close(() => done()); }),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { HostConnection } from '../ahp/connection.js';
|
|
2
|
+
/** The newest version of MCP this speaks, and what it answers an unknown one with. */
|
|
3
|
+
export declare const PROTOCOL = "2025-06-18";
|
|
4
|
+
/**
|
|
5
|
+
* Every version this will serve, newest first.
|
|
6
|
+
*
|
|
7
|
+
* Both of these describe the same tools, and this server uses nothing either
|
|
8
|
+
* of them added or removed - no sessions, no resources, no sampling - so
|
|
9
|
+
* refusing the older one would be refusing a client for a difference that
|
|
10
|
+
* cannot reach it. `2024-11-05` is *not* here: its HTTP transport is a
|
|
11
|
+
* different shape, with an `endpoint` event and a second URL.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SPOKEN: readonly string[];
|
|
14
|
+
/**
|
|
15
|
+
* What this server calls itself.
|
|
16
|
+
*
|
|
17
|
+
* MCP requires a version where AHP's `clientInfo` does not. Read from the
|
|
18
|
+
* manifest rather than written here: the literal that used to be here said
|
|
19
|
+
* 0.1 while the package said 0.2, within a day of being written.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SERVER: {
|
|
22
|
+
name: string;
|
|
23
|
+
version: string;
|
|
24
|
+
};
|
|
25
|
+
/** A JSON-RPC request or notification, as far as this needs to read one. */
|
|
26
|
+
export interface Incoming {
|
|
27
|
+
jsonrpc?: unknown;
|
|
28
|
+
id?: number | string | null;
|
|
29
|
+
method?: unknown;
|
|
30
|
+
params?: unknown;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* One progress notification, as `notifications/progress` puts it on the wire.
|
|
34
|
+
*
|
|
35
|
+
* `progress` MUST increase on every one, `total` is left out because an
|
|
36
|
+
* agent's reply has no length known in advance, and `message` is what a person
|
|
37
|
+
* reads - which is the tool name a session stopped on, or the host's own words
|
|
38
|
+
* for what it is doing.
|
|
39
|
+
*/
|
|
40
|
+
export interface Progress {
|
|
41
|
+
progressToken: string | number;
|
|
42
|
+
progress: number;
|
|
43
|
+
message?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Somewhere to put progress while a tool runs, or nothing where nobody asked. */
|
|
46
|
+
export type Report = ((said: string) => void) | undefined;
|
|
47
|
+
/** What goes back, or nothing at all where the message was a notification. */
|
|
48
|
+
export type Outgoing = {
|
|
49
|
+
jsonrpc: '2.0';
|
|
50
|
+
id: number | string | null;
|
|
51
|
+
} & ({
|
|
52
|
+
result: unknown;
|
|
53
|
+
error?: never;
|
|
54
|
+
} | {
|
|
55
|
+
error: {
|
|
56
|
+
code: number;
|
|
57
|
+
message: string;
|
|
58
|
+
};
|
|
59
|
+
result?: never;
|
|
60
|
+
});
|
|
61
|
+
/** The tools, in the shape `tools/list` puts them on the wire. */
|
|
62
|
+
export declare const listing: (groups?: readonly string[]) => unknown;
|
|
63
|
+
/**
|
|
64
|
+
* Run one tool and shape the answer the way `tools/call` wants it.
|
|
65
|
+
*
|
|
66
|
+
* A tool that throws is *not* a protocol error. MCP has `isError` on the
|
|
67
|
+
* result for exactly this: the call reached the tool and the tool said no,
|
|
68
|
+
* which a model can read and act on, where a JSON-RPC error is a transport
|
|
69
|
+
* fault it can only give up over. So a refusal from the host - a session that
|
|
70
|
+
* is gone, a directory it does not serve - comes back as content.
|
|
71
|
+
*/
|
|
72
|
+
export declare function call(host: HostConnection, name: string, input: unknown, report?: Report, groups?: readonly string[]): Promise<unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* Answer one message.
|
|
75
|
+
*
|
|
76
|
+
* `undefined` where there is nothing to send back, which is a notification -
|
|
77
|
+
* and answering one anyway is a protocol error on this end, not a courtesy.
|
|
78
|
+
*/
|
|
79
|
+
export declare function answer(host: HostConnection, message: Incoming, options: {
|
|
80
|
+
name: string;
|
|
81
|
+
version: string;
|
|
82
|
+
/** The opt-in tool groups this server was started with, if any. */
|
|
83
|
+
groups?: readonly string[];
|
|
84
|
+
/**
|
|
85
|
+
* Somewhere to send a notification while this is being answered.
|
|
86
|
+
*
|
|
87
|
+
* Given by a transport that has one: stdio always does, and HTTP does only
|
|
88
|
+
* once it has decided to answer with a stream. Absent means there is
|
|
89
|
+
* nowhere to say anything until the result, which is the ordinary case and
|
|
90
|
+
* why every progress path here is optional.
|
|
91
|
+
*/
|
|
92
|
+
notify?(notification: {
|
|
93
|
+
method: string;
|
|
94
|
+
params: unknown;
|
|
95
|
+
}): void;
|
|
96
|
+
}): Promise<Outgoing | undefined>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MCP, spoken directly.
|
|
3
|
+
*
|
|
4
|
+
* The protocol a tools-only server has to answer is small: `initialize`, an
|
|
5
|
+
* `initialized` notification with no reply, `tools/list`, `tools/call`, and
|
|
6
|
+
* `ping`. All of it is JSON-RPC 2.0, which this client already reads and
|
|
7
|
+
* writes for AHP. An SDK for those five would be a dependency the published
|
|
8
|
+
* CLI carries into every install to save a hundred lines - the same trade
|
|
9
|
+
* `flags.ts` refuses for argv.
|
|
10
|
+
*
|
|
11
|
+
* What is deliberately not here: prompts, resources, sampling, roots and
|
|
12
|
+
* subscriptions. A server that advertises no capability for them is not
|
|
13
|
+
* obliged to answer them, and a caller that asks is told the method is not
|
|
14
|
+
* there, which is the truth.
|
|
15
|
+
*/
|
|
16
|
+
import { named, served } from './tools.js';
|
|
17
|
+
import { version } from '../version.js';
|
|
18
|
+
/** The newest version of MCP this speaks, and what it answers an unknown one with. */
|
|
19
|
+
export const PROTOCOL = '2025-06-18';
|
|
20
|
+
/**
|
|
21
|
+
* Every version this will serve, newest first.
|
|
22
|
+
*
|
|
23
|
+
* Both of these describe the same tools, and this server uses nothing either
|
|
24
|
+
* of them added or removed - no sessions, no resources, no sampling - so
|
|
25
|
+
* refusing the older one would be refusing a client for a difference that
|
|
26
|
+
* cannot reach it. `2024-11-05` is *not* here: its HTTP transport is a
|
|
27
|
+
* different shape, with an `endpoint` event and a second URL.
|
|
28
|
+
*/
|
|
29
|
+
export const SPOKEN = ['2025-06-18', '2025-03-26'];
|
|
30
|
+
/**
|
|
31
|
+
* What this server calls itself.
|
|
32
|
+
*
|
|
33
|
+
* MCP requires a version where AHP's `clientInfo` does not. Read from the
|
|
34
|
+
* manifest rather than written here: the literal that used to be here said
|
|
35
|
+
* 0.1 while the package said 0.2, within a day of being written.
|
|
36
|
+
*/
|
|
37
|
+
export const SERVER = { name: 'ahpc', version: version() };
|
|
38
|
+
const METHOD_NOT_FOUND = -32601;
|
|
39
|
+
const INVALID_PARAMS = -32602;
|
|
40
|
+
const INTERNAL = -32603;
|
|
41
|
+
const bag = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {});
|
|
42
|
+
/** The tools, in the shape `tools/list` puts them on the wire. */
|
|
43
|
+
export const listing = (groups = []) => ({
|
|
44
|
+
tools: served(groups).map((tool) => ({
|
|
45
|
+
name: tool.name,
|
|
46
|
+
title: tool.title,
|
|
47
|
+
description: tool.description,
|
|
48
|
+
inputSchema: tool.input,
|
|
49
|
+
annotations: { readOnlyHint: tool.readOnly },
|
|
50
|
+
})),
|
|
51
|
+
});
|
|
52
|
+
/**
|
|
53
|
+
* Run one tool and shape the answer the way `tools/call` wants it.
|
|
54
|
+
*
|
|
55
|
+
* A tool that throws is *not* a protocol error. MCP has `isError` on the
|
|
56
|
+
* result for exactly this: the call reached the tool and the tool said no,
|
|
57
|
+
* which a model can read and act on, where a JSON-RPC error is a transport
|
|
58
|
+
* fault it can only give up over. So a refusal from the host - a session that
|
|
59
|
+
* is gone, a directory it does not serve - comes back as content.
|
|
60
|
+
*/
|
|
61
|
+
export async function call(host, name, input, report, groups = []) {
|
|
62
|
+
const tool = named(name);
|
|
63
|
+
if (tool === undefined) {
|
|
64
|
+
return {
|
|
65
|
+
isError: true,
|
|
66
|
+
content: [{ type: 'text', text: `No tool called ${name}. Ask tools/list for what there is.` }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Exists, but this server was not started with it. Said as itself rather
|
|
70
|
+
// than as "no such tool", because the difference is one flag and only the
|
|
71
|
+
// person who started this can supply it.
|
|
72
|
+
if (tool.group !== undefined && !groups.includes(tool.group)) {
|
|
73
|
+
return {
|
|
74
|
+
isError: true,
|
|
75
|
+
content: [{ type: 'text', text: `${name} is in the ${tool.group} group, which this server was not started with. It needs --mcp-tools ${tool.group}.` }],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const answer = await tool.run(host, bag(input), report);
|
|
80
|
+
return {
|
|
81
|
+
// Both, because clients differ: the text is what a model reads and
|
|
82
|
+
// `structuredContent` is what a program does, and sending only the
|
|
83
|
+
// second leaves older clients with an empty result.
|
|
84
|
+
content: [{ type: 'text', text: JSON.stringify(answer, null, 2) }],
|
|
85
|
+
structuredContent: bag(answer).constructor === Object && !Array.isArray(answer) && answer !== null
|
|
86
|
+
? answer
|
|
87
|
+
: { result: answer },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
isError: true,
|
|
93
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Somewhere for a running tool to say what it is doing, if anybody asked.
|
|
99
|
+
*
|
|
100
|
+
* A caller opts in by putting a `progressToken` in `_meta` on the request, and
|
|
101
|
+
* `undefined` here means it did not - in which case a tool that reports
|
|
102
|
+
* anything is writing to nowhere, which is exactly what should happen. The
|
|
103
|
+
* counter is this closure's, because `progress` MUST increase and a tool
|
|
104
|
+
* counting for itself would be a tool that has to know about the protocol.
|
|
105
|
+
*/
|
|
106
|
+
function reporter(message, notify) {
|
|
107
|
+
const token = bag(bag(message.params)._meta).progressToken;
|
|
108
|
+
if (notify === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
if (typeof token !== 'string' && typeof token !== 'number')
|
|
111
|
+
return undefined;
|
|
112
|
+
let count = 0;
|
|
113
|
+
return (said) => {
|
|
114
|
+
count += 1;
|
|
115
|
+
const params = { progressToken: token, progress: count, message: said };
|
|
116
|
+
notify({ method: 'notifications/progress', params });
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Answer one message.
|
|
121
|
+
*
|
|
122
|
+
* `undefined` where there is nothing to send back, which is a notification -
|
|
123
|
+
* and answering one anyway is a protocol error on this end, not a courtesy.
|
|
124
|
+
*/
|
|
125
|
+
export async function answer(host, message, options) {
|
|
126
|
+
const method = typeof message.method === 'string' ? message.method : '';
|
|
127
|
+
const id = message.id ?? null;
|
|
128
|
+
const notification = message.id === undefined;
|
|
129
|
+
if (method === 'notifications/initialized' || method.startsWith('notifications/'))
|
|
130
|
+
return undefined;
|
|
131
|
+
if (notification)
|
|
132
|
+
return undefined;
|
|
133
|
+
const ok = (result) => ({ jsonrpc: '2.0', id, result });
|
|
134
|
+
const no = (code, said) => ({ jsonrpc: '2.0', id, error: { code, message: said } });
|
|
135
|
+
if (method === 'initialize') {
|
|
136
|
+
/*
|
|
137
|
+
* The client's version where this can speak it, and the newest otherwise.
|
|
138
|
+
*
|
|
139
|
+
* `lifecycle` says a server answering a version it was not asked for is
|
|
140
|
+
* telling the client to take that one or disconnect, so answering our
|
|
141
|
+
* own regardless would refuse every client a release behind for no
|
|
142
|
+
* reason. A version this cannot speak gets the newest it can, which is
|
|
143
|
+
* the offer the client then accepts or drops.
|
|
144
|
+
*/
|
|
145
|
+
const asked = bag(message.params).protocolVersion;
|
|
146
|
+
return ok({
|
|
147
|
+
protocolVersion: typeof asked === 'string' && SPOKEN.includes(asked) ? asked : PROTOCOL,
|
|
148
|
+
capabilities: { tools: { listChanged: false } },
|
|
149
|
+
serverInfo: { name: options.name, version: options.version },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (method === 'ping')
|
|
153
|
+
return ok({});
|
|
154
|
+
if (method === 'tools/list')
|
|
155
|
+
return ok(listing(options.groups));
|
|
156
|
+
if (method === 'tools/call') {
|
|
157
|
+
const params = bag(message.params);
|
|
158
|
+
const name = typeof params.name === 'string' ? params.name : '';
|
|
159
|
+
if (name === '')
|
|
160
|
+
return no(INVALID_PARAMS, 'tools/call needs a name.');
|
|
161
|
+
try {
|
|
162
|
+
return ok(await call(host, name, params.arguments, reporter(message, options.notify), options.groups));
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return no(INTERNAL, error instanceof Error ? error.message : String(error));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return no(METHOD_NOT_FOUND, `This server does not implement ${method}. It serves tools and nothing else.`);
|
|
169
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HostConnection } from '../ahp/connection.js';
|
|
2
|
+
/**
|
|
3
|
+
* Serve until stdin closes.
|
|
4
|
+
*
|
|
5
|
+
* Sequential rather than concurrent: `send_turn` blocks for as long as a model
|
|
6
|
+
* takes, and running the next message while it waits would answer out of
|
|
7
|
+
* order. A client that wants two things at once opens two sessions.
|
|
8
|
+
*/
|
|
9
|
+
export declare function stdio(host: HostConnection, options: {
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
groups?: readonly string[];
|
|
13
|
+
onProblem?(said: string): void;
|
|
14
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MCP over the process's own standard streams.
|
|
3
|
+
*
|
|
4
|
+
* The transport a client that launched this process uses: one JSON-RPC
|
|
5
|
+
* message per line on stdin, one per line on stdout, and nothing else on
|
|
6
|
+
* stdout ever - a stray `console.log` here is a parse error at the other end,
|
|
7
|
+
* which is why everything this says goes to stderr.
|
|
8
|
+
*/
|
|
9
|
+
import { answer } from './serve.js';
|
|
10
|
+
/**
|
|
11
|
+
* Serve until stdin closes.
|
|
12
|
+
*
|
|
13
|
+
* Sequential rather than concurrent: `send_turn` blocks for as long as a model
|
|
14
|
+
* takes, and running the next message while it waits would answer out of
|
|
15
|
+
* order. A client that wants two things at once opens two sessions.
|
|
16
|
+
*/
|
|
17
|
+
export async function stdio(host, options) {
|
|
18
|
+
const say = (value) => { process.stdout.write(`${JSON.stringify(value)}\n`); };
|
|
19
|
+
let rest = '';
|
|
20
|
+
await new Promise((done) => {
|
|
21
|
+
let running = Promise.resolve();
|
|
22
|
+
const take = (line) => {
|
|
23
|
+
const said = line.trim();
|
|
24
|
+
if (said === '')
|
|
25
|
+
return;
|
|
26
|
+
running = running.then(async () => {
|
|
27
|
+
let message;
|
|
28
|
+
try {
|
|
29
|
+
message = JSON.parse(said);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// No id to answer against, so there is nobody to tell. Said on
|
|
33
|
+
// stderr, where a person debugging their client will find it.
|
|
34
|
+
options.onProblem?.(`Not JSON: ${said.slice(0, 200)}`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const reply = await answer(host, message, {
|
|
38
|
+
...options,
|
|
39
|
+
// A notification mid-request is free here: stdout is a stream and
|
|
40
|
+
// the client is already reading lines off it. The HTTP half has to
|
|
41
|
+
// choose a response type before it can say anything at all.
|
|
42
|
+
notify: (notification) => say({ jsonrpc: '2.0', ...notification }),
|
|
43
|
+
});
|
|
44
|
+
if (reply !== undefined)
|
|
45
|
+
say(reply);
|
|
46
|
+
}).catch((error) => {
|
|
47
|
+
options.onProblem?.(error instanceof Error ? error.message : String(error));
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
process.stdin.setEncoding('utf8');
|
|
51
|
+
process.stdin.on('data', (chunk) => {
|
|
52
|
+
rest += chunk;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const at = rest.indexOf('\n');
|
|
55
|
+
if (at === -1)
|
|
56
|
+
break;
|
|
57
|
+
take(rest.slice(0, at));
|
|
58
|
+
rest = rest.slice(at + 1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
process.stdin.on('end', () => { take(rest); rest = ''; void running.then(() => done()); });
|
|
62
|
+
process.stdin.on('close', () => { void running.then(() => done()); });
|
|
63
|
+
});
|
|
64
|
+
}
|