@nones/dsh-session-mcp 1.1.0 → 1.2.1
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 +12 -3
- package/dist/client.js +120 -14
- package/dist/config.d.ts +27 -0
- package/dist/index.js +49 -10
- package/dist/log.d.ts +54 -0
- package/dist/log.js +51 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +4 -4
- package/dist/tools.d.ts +3 -1
- package/dist/tools.js +61 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,8 +49,13 @@ Restart the harness (or reload the profile) and the endpoint starts with it.
|
|
|
49
49
|
|
|
50
50
|
## Get your config
|
|
51
51
|
|
|
52
|
-
Open **Settings → MCP** in the harness. The page shows
|
|
53
|
-
|
|
52
|
+
Open **Settings → MCP** in the harness. The page shows:
|
|
53
|
+
|
|
54
|
+
- **Status** — listening, uptime, total requests, and the last failure when one happened
|
|
55
|
+
- **Recent requests** — the newest five tool calls: what ran, when, the conversation it
|
|
56
|
+
addressed, and whether it succeeded (with duration)
|
|
57
|
+
- the live endpoint and the bearer token (masked until you reveal it)
|
|
58
|
+
- three copy-ready blocks:
|
|
54
59
|
|
|
55
60
|
| Block | For |
|
|
56
61
|
|---|---|
|
|
@@ -58,7 +63,11 @@ token (masked until you reveal it), and three copy-ready blocks:
|
|
|
58
63
|
| Codex — `~/.codex/config.toml` | Codex CLI / IDE |
|
|
59
64
|
| Claude Code — CLI | One `claude mcp add …` command |
|
|
60
65
|
|
|
61
|
-
Each block has its own **Copy** button; the endpoint and the token have one too.
|
|
66
|
+
Each block has its own **Copy** button; the endpoint and the token have one too. The page
|
|
67
|
+
refreshes every 10 seconds while it is open.
|
|
68
|
+
|
|
69
|
+
Only the last five calls are kept — the plugin is not a log store, and schema-rejected
|
|
70
|
+
calls (wrong arguments) never reach a tool, so they are not journalled.
|
|
62
71
|
|
|
63
72
|
The same data is on disk at `$DSH_HOME/state/dsh-session-mcp.json`:
|
|
64
73
|
|
package/dist/client.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* dsh-session-mcp — Client half.
|
|
4
4
|
*
|
|
5
|
-
* Registers the MCP settings page:
|
|
6
|
-
* copy-ready configuration blocks for every
|
|
5
|
+
* Registers the MCP settings page: live status, the last few requests, the
|
|
6
|
+
* endpoint, the bearer token, and copy-ready configuration blocks for every
|
|
7
|
+
* client this harness can serve.
|
|
7
8
|
*
|
|
8
9
|
* The page reads the config over the authenticated `/api` channel, so the token
|
|
9
10
|
* never appears in the served HTML.
|
|
@@ -24,12 +25,25 @@ globalThis.__ModuleLoader__.load({
|
|
|
24
25
|
const h = React.createElement;
|
|
25
26
|
/** Locale namespace owned by this page. */
|
|
26
27
|
const NS = 'dsh-session-mcp';
|
|
27
|
-
/** Where the Host publishes the live config. */
|
|
28
|
+
/** Where the Host publishes the live config, status and request journal. */
|
|
28
29
|
const CONFIG_URL = '/api/dsh-session-mcp/config';
|
|
30
|
+
/** How often the page refreshes while open. */
|
|
31
|
+
const REFRESH_MS = 10000;
|
|
29
32
|
const EN = {
|
|
30
33
|
title: 'MCP',
|
|
31
34
|
heading: 'MCP endpoint',
|
|
32
35
|
description: 'Connect Claude Code, Codex, Cursor or any other MCP client to this harness. The endpoint starts together with the harness and requires the bearer token below.',
|
|
36
|
+
statusTitle: 'Status',
|
|
37
|
+
listening: 'Listening',
|
|
38
|
+
uptime: 'Uptime',
|
|
39
|
+
requests: 'Requests',
|
|
40
|
+
lastError: 'Last error',
|
|
41
|
+
logTitle: 'Recent requests',
|
|
42
|
+
logEmpty: 'No requests yet. Connect a client and call a tool.',
|
|
43
|
+
colTime: 'Time',
|
|
44
|
+
colTool: 'Request',
|
|
45
|
+
colSession: 'Conversation',
|
|
46
|
+
colResult: 'Result',
|
|
33
47
|
endpoint: 'Endpoint',
|
|
34
48
|
bearer: 'Bearer token',
|
|
35
49
|
show: 'Show',
|
|
@@ -42,11 +56,24 @@ globalThis.__ModuleLoader__.load({
|
|
|
42
56
|
note: 'Paste any block into your client. The token is stable across restarts; it rotates only when you delete $DSH_HOME/state/dsh-session-mcp.json and restart the harness.',
|
|
43
57
|
loading: 'Loading…',
|
|
44
58
|
error: 'Could not read the endpoint config: ',
|
|
59
|
+
noSession: '—',
|
|
60
|
+
failed: 'error',
|
|
45
61
|
};
|
|
46
62
|
const RU = {
|
|
47
63
|
title: 'MCP',
|
|
48
64
|
heading: 'MCP-эндпоинт',
|
|
49
65
|
description: 'Подключите Claude Code, Codex, Cursor или любой другой MCP-клиент к этому харнесу. Эндпоинт поднимается вместе с харнесом и требует Bearer-токен ниже.',
|
|
66
|
+
statusTitle: 'Статус',
|
|
67
|
+
listening: 'Слушает',
|
|
68
|
+
uptime: 'В работе',
|
|
69
|
+
requests: 'Запросов',
|
|
70
|
+
lastError: 'Последняя ошибка',
|
|
71
|
+
logTitle: 'Последние запросы',
|
|
72
|
+
logEmpty: 'Запросов пока нет. Подключите клиент и вызовите инструмент.',
|
|
73
|
+
colTime: 'Время',
|
|
74
|
+
colTool: 'Запрос',
|
|
75
|
+
colSession: 'Сессия',
|
|
76
|
+
colResult: 'Результат',
|
|
50
77
|
endpoint: 'Эндпоинт',
|
|
51
78
|
bearer: 'Bearer-токен',
|
|
52
79
|
show: 'Показать',
|
|
@@ -59,6 +86,8 @@ globalThis.__ModuleLoader__.load({
|
|
|
59
86
|
note: 'Вставьте любой блок в свой клиент. Токен не меняется между перезапусками; он обновится только если удалить $DSH_HOME/state/dsh-session-mcp.json и перезапустить харнес.',
|
|
60
87
|
loading: 'Загрузка…',
|
|
61
88
|
error: 'Не удалось прочитать конфиг эндпоинта: ',
|
|
89
|
+
noSession: '—',
|
|
90
|
+
failed: 'ошибка',
|
|
62
91
|
};
|
|
63
92
|
/** Theme-bound inline styles; no colors are hardcoded. */
|
|
64
93
|
const styles = {
|
|
@@ -112,6 +141,40 @@ globalThis.__ModuleLoader__.load({
|
|
|
112
141
|
color: 'var(--dsw-alias-label-primary)',
|
|
113
142
|
cursor: 'pointer',
|
|
114
143
|
},
|
|
144
|
+
pillOk: {
|
|
145
|
+
display: 'inline-block',
|
|
146
|
+
padding: '1px 9px',
|
|
147
|
+
borderRadius: '999px',
|
|
148
|
+
fontSize: '11px',
|
|
149
|
+
border: '1px solid var(--dsw-alias-state-success-primary)',
|
|
150
|
+
color: 'var(--dsw-alias-state-success-primary)',
|
|
151
|
+
whiteSpace: 'nowrap',
|
|
152
|
+
},
|
|
153
|
+
pillError: {
|
|
154
|
+
display: 'inline-block',
|
|
155
|
+
padding: '1px 9px',
|
|
156
|
+
borderRadius: '999px',
|
|
157
|
+
fontSize: '11px',
|
|
158
|
+
border: '1px solid var(--dsw-alias-state-error-primary)',
|
|
159
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
160
|
+
whiteSpace: 'nowrap',
|
|
161
|
+
},
|
|
162
|
+
table: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' },
|
|
163
|
+
th: {
|
|
164
|
+
textAlign: 'left',
|
|
165
|
+
fontWeight: 600,
|
|
166
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
167
|
+
padding: '0 8px 6px 0',
|
|
168
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1)',
|
|
169
|
+
whiteSpace: 'nowrap',
|
|
170
|
+
},
|
|
171
|
+
td: {
|
|
172
|
+
padding: '6px 8px 6px 0',
|
|
173
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1)',
|
|
174
|
+
verticalAlign: 'top',
|
|
175
|
+
},
|
|
176
|
+
mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
|
|
177
|
+
muted: { color: 'var(--dsw-alias-label-secondary)' },
|
|
115
178
|
pre: {
|
|
116
179
|
margin: 0,
|
|
117
180
|
padding: '12px',
|
|
@@ -128,6 +191,24 @@ globalThis.__ModuleLoader__.load({
|
|
|
128
191
|
note: { margin: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' },
|
|
129
192
|
error: { margin: 0, color: 'var(--dsw-alias-state-error-primary)' },
|
|
130
193
|
};
|
|
194
|
+
/** `45s`, `12m 3s`, `1h 12m`. */
|
|
195
|
+
function formatUptime(ms) {
|
|
196
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
197
|
+
if (total < 60)
|
|
198
|
+
return `${String(total)}s`;
|
|
199
|
+
const minutes = Math.floor(total / 60);
|
|
200
|
+
if (minutes < 60)
|
|
201
|
+
return `${String(minutes)}m ${String(total % 60)}s`;
|
|
202
|
+
const hours = Math.floor(minutes / 60);
|
|
203
|
+
if (hours < 24)
|
|
204
|
+
return `${String(hours)}h ${String(minutes % 60)}m`;
|
|
205
|
+
return `${String(Math.floor(hours / 24))}d ${String(hours % 24)}h`;
|
|
206
|
+
}
|
|
207
|
+
/** Local wall-clock time for one ISO timestamp. */
|
|
208
|
+
function formatTime(iso) {
|
|
209
|
+
const date = new Date(iso);
|
|
210
|
+
return Number.isNaN(date.getTime()) ? iso : date.toLocaleTimeString();
|
|
211
|
+
}
|
|
131
212
|
return {
|
|
132
213
|
inject: ['slots', 'locale'],
|
|
133
214
|
apply(ctx) {
|
|
@@ -155,24 +236,49 @@ globalThis.__ModuleLoader__.load({
|
|
|
155
236
|
function Snippet(props) {
|
|
156
237
|
return h('section', { style: styles.card }, h('div', { style: styles.cardHead }, h('span', { style: styles.cardTitle }, props.title), h(CopyButton, { text: props.text })), h('pre', { style: styles.pre }, props.text));
|
|
157
238
|
}
|
|
239
|
+
function StatusCard(props) {
|
|
240
|
+
const status = props.status;
|
|
241
|
+
const failure = status.lastFailure;
|
|
242
|
+
return h('section', { style: styles.card }, h('div', { style: styles.cardHead }, h('span', { style: styles.cardTitle }, t('statusTitle')), h('span', { style: styles.pillOk }, t('listening'))), h('div', { style: styles.row }, h('span', { style: styles.rowLabel }, t('uptime')), h('span', { style: styles.rowValue }, formatUptime(Number(status.uptimeMs)))), h('div', { style: styles.row }, h('span', { style: styles.rowLabel }, t('requests')), h('span', { style: styles.rowValue }, String(status.totalCalls))), failure === null || failure === undefined
|
|
243
|
+
? null
|
|
244
|
+
: h('div', { style: styles.row }, h('span', { style: styles.rowLabel }, t('lastError')), h('span', { style: { ...styles.rowValue, color: 'var(--dsw-alias-state-error-primary)' } }, `${String(failure.tool)} · ${formatTime(String(failure.at))} · ${String(failure.message)}`)));
|
|
245
|
+
}
|
|
246
|
+
function LogCard(props) {
|
|
247
|
+
const entries = props.entries ?? [];
|
|
248
|
+
const head = h('div', { style: styles.cardHead }, h('span', { style: styles.cardTitle }, t('logTitle')), h('span', { style: styles.muted }, String(entries.length)));
|
|
249
|
+
if (entries.length === 0) {
|
|
250
|
+
return h('section', { style: styles.card }, head, h('p', { style: styles.note }, t('logEmpty')));
|
|
251
|
+
}
|
|
252
|
+
const rows = entries.map((entry) => h('tr', { key: String(entry.id) }, h('td', { style: { ...styles.td, ...styles.mono, whiteSpace: 'nowrap' } }, formatTime(String(entry.at))), h('td', { style: { ...styles.td, ...styles.mono } }, String(entry.tool)), h('td', { style: styles.td }, entry.sessionId === null
|
|
253
|
+
? h('span', { style: styles.muted }, t('noSession'))
|
|
254
|
+
: h('span', { title: String(entry.sessionId) }, String(entry.sessionTitle ?? `${String(entry.sessionId).slice(0, 14)}…`))), h('td', { style: { ...styles.td, whiteSpace: 'nowrap' } }, entry.ok
|
|
255
|
+
? h('span', { style: styles.pillOk }, `ok · ${String(entry.ms)}ms`)
|
|
256
|
+
: h('span', { style: styles.pillError, title: String(entry.error ?? '') }, t('failed')))));
|
|
257
|
+
return h('section', { style: styles.card }, head, h('table', { style: styles.table }, h('thead', null, h('tr', null, h('th', { style: styles.th }, t('colTime')), h('th', { style: styles.th }, t('colTool')), h('th', { style: styles.th }, t('colSession')), h('th', { style: styles.th }, t('colResult')))), h('tbody', null, rows)));
|
|
258
|
+
}
|
|
158
259
|
function Section() {
|
|
159
260
|
useLocaleRevision();
|
|
160
261
|
const [state, setState] = React.useState({ status: 'loading' });
|
|
161
262
|
const [revealed, setRevealed] = React.useState(false);
|
|
162
263
|
React.useEffect(() => {
|
|
163
264
|
let alive = true;
|
|
164
|
-
|
|
165
|
-
.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
265
|
+
const load = () => {
|
|
266
|
+
g.fetch(CONFIG_URL, { headers: { accept: 'application/json' }, credentials: 'same-origin' })
|
|
267
|
+
.then((response) => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${String(response.status)}`)))
|
|
268
|
+
.then((data) => {
|
|
269
|
+
if (alive)
|
|
270
|
+
setState({ status: 'ready', data });
|
|
271
|
+
})
|
|
272
|
+
.catch((error) => {
|
|
273
|
+
if (alive)
|
|
274
|
+
setState({ status: 'error', message: String(error?.message ?? error) });
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
load();
|
|
278
|
+
const timer = g.setInterval(load, REFRESH_MS);
|
|
174
279
|
return () => {
|
|
175
280
|
alive = false;
|
|
281
|
+
g.clearInterval(timer);
|
|
176
282
|
};
|
|
177
283
|
}, []);
|
|
178
284
|
const header = h('div', { style: { display: 'grid', gap: '4px' } }, h('h2', { style: styles.heading }, t('heading')), h('p', { style: styles.description }, t('description')));
|
|
@@ -185,7 +291,7 @@ globalThis.__ModuleLoader__.load({
|
|
|
185
291
|
const data = state.data;
|
|
186
292
|
const token = String(data.token);
|
|
187
293
|
const masked = `${token.slice(0, 6)}…${token.slice(-4)}`;
|
|
188
|
-
return h('div', { style: styles.page }, header, h('section', { style: styles.card }, h('div', { style: styles.row }, h('span', { style: styles.rowLabel }, t('endpoint')), h('span', { style: styles.rowValue }, data.url), h('div', { style: styles.actions }, h(CopyButton, { text: data.url }))), h('div', { style: styles.row }, h('span', { style: styles.rowLabel }, t('bearer')), h('span', { style: styles.rowValue }, revealed ? token : masked), h('div', { style: styles.actions }, h('button', {
|
|
294
|
+
return h('div', { style: styles.page }, header, h(StatusCard, { status: data.status }), h(LogCard, { entries: data.log ?? [] }), h('section', { style: styles.card }, h('div', { style: styles.row }, h('span', { style: styles.rowLabel }, t('endpoint')), h('span', { style: styles.rowValue }, data.url), h('div', { style: styles.actions }, h(CopyButton, { text: data.url }))), h('div', { style: styles.row }, h('span', { style: styles.rowLabel }, t('bearer')), h('span', { style: styles.rowValue }, revealed ? token : masked), h('div', { style: styles.actions }, h('button', {
|
|
189
295
|
type: 'button',
|
|
190
296
|
style: styles.button,
|
|
191
297
|
onClick: () => setRevealed((value) => !value),
|
package/dist/config.d.ts
CHANGED
|
@@ -93,6 +93,31 @@ export interface ConfigSnippets {
|
|
|
93
93
|
* @returns the three snippets the Settings page shows.
|
|
94
94
|
*/
|
|
95
95
|
export declare function snippetsFor(url: string, token: string): ConfigSnippets;
|
|
96
|
+
/** Live health of the MCP endpoint, as the Settings page shows it. */
|
|
97
|
+
export interface StatusPayload {
|
|
98
|
+
/** `listening` while the plugin serves; the page never renders a dead endpoint as healthy. */
|
|
99
|
+
readonly state: 'listening';
|
|
100
|
+
readonly version: string;
|
|
101
|
+
readonly startedAt: string;
|
|
102
|
+
readonly uptimeMs: number;
|
|
103
|
+
readonly totalCalls: number;
|
|
104
|
+
readonly lastFailure: {
|
|
105
|
+
readonly at: string;
|
|
106
|
+
readonly tool: string;
|
|
107
|
+
readonly message: string;
|
|
108
|
+
} | null;
|
|
109
|
+
}
|
|
110
|
+
/** One journalled call with the conversation title resolved for display. */
|
|
111
|
+
export interface LogEntry {
|
|
112
|
+
readonly id: number;
|
|
113
|
+
readonly tool: string;
|
|
114
|
+
readonly at: string;
|
|
115
|
+
readonly sessionId: string | null;
|
|
116
|
+
readonly sessionTitle: string | null;
|
|
117
|
+
readonly ok: boolean;
|
|
118
|
+
readonly ms: number;
|
|
119
|
+
readonly error?: string;
|
|
120
|
+
}
|
|
96
121
|
/** What the Settings page receives from the Host. */
|
|
97
122
|
export interface ConfigPayload {
|
|
98
123
|
readonly name: string;
|
|
@@ -102,4 +127,6 @@ export interface ConfigPayload {
|
|
|
102
127
|
readonly token: string;
|
|
103
128
|
readonly updatedAt: string;
|
|
104
129
|
readonly snippets: ConfigSnippets;
|
|
130
|
+
readonly status: StatusPayload;
|
|
131
|
+
readonly log: readonly LogEntry[];
|
|
105
132
|
}
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { readFileSync } from 'node:fs';
|
|
16
16
|
import { clientConfig, CONFIG_ROUTE_PATH, createToken, DEFAULT_HOST, DEFAULT_PORT, readState, SERVER_NAME, snippetsFor, writeState, } from './config.js';
|
|
17
|
+
import { RequestJournal } from './log.js';
|
|
17
18
|
import { startEndpoint } from './server.js';
|
|
18
19
|
/** Stable Cordis plugin name. */
|
|
19
20
|
export const name = 'dsh-session-mcp';
|
|
@@ -61,6 +62,7 @@ export async function apply(ctx, config) {
|
|
|
61
62
|
const stored = readState();
|
|
62
63
|
const token = stored?.token ?? createToken();
|
|
63
64
|
const createdAt = stored?.createdAt ?? new Date().toISOString();
|
|
65
|
+
const journal = new RequestJournal();
|
|
64
66
|
const endpoint = await startEndpoint({
|
|
65
67
|
controller: ctx.sessionController,
|
|
66
68
|
token,
|
|
@@ -68,6 +70,7 @@ export async function apply(ctx, config) {
|
|
|
68
70
|
port,
|
|
69
71
|
version,
|
|
70
72
|
logger: ctx.logger,
|
|
73
|
+
journal,
|
|
71
74
|
});
|
|
72
75
|
const state = {
|
|
73
76
|
version: 1,
|
|
@@ -85,23 +88,59 @@ export async function apply(ctx, config) {
|
|
|
85
88
|
ctx.logger?.info?.(`dsh-session-mcp: bearer token and client config in ${file}`);
|
|
86
89
|
console.log(`dsh-session-mcp: ${endpoint.url} (bearer in ${file})`);
|
|
87
90
|
console.log(JSON.stringify(state.client, null, 2));
|
|
91
|
+
/** Resolve conversation titles for the journalled calls, best effort. */
|
|
92
|
+
const titlesFor = async (ids) => {
|
|
93
|
+
const titles = new Map();
|
|
94
|
+
if (ids.length === 0)
|
|
95
|
+
return titles;
|
|
96
|
+
try {
|
|
97
|
+
const rows = await ctx.sessionController.list({}, new AbortController().signal);
|
|
98
|
+
for (const row of rows?.items ?? []) {
|
|
99
|
+
if (ids.includes(row.sessionId))
|
|
100
|
+
titles.set(row.sessionId, row.projections?.values?.title ?? null);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* Titles decorate the log; a failed lookup must not fail the page. */
|
|
105
|
+
}
|
|
106
|
+
return titles;
|
|
107
|
+
};
|
|
108
|
+
/** Build the Settings payload fresh on every read. */
|
|
109
|
+
const buildPayload = async () => {
|
|
110
|
+
const records = journal.snapshot();
|
|
111
|
+
const ids = [...new Set(records.map((entry) => entry.sessionId).filter((id) => id !== null))];
|
|
112
|
+
const titles = await titlesFor(ids);
|
|
113
|
+
const log = records.map((entry) => ({
|
|
114
|
+
...entry,
|
|
115
|
+
sessionTitle: entry.sessionId === null ? null : (titles.get(entry.sessionId) ?? null),
|
|
116
|
+
}));
|
|
117
|
+
return {
|
|
118
|
+
name: SERVER_NAME,
|
|
119
|
+
url: endpoint.url,
|
|
120
|
+
host,
|
|
121
|
+
port: endpoint.port,
|
|
122
|
+
token,
|
|
123
|
+
updatedAt: state.updatedAt,
|
|
124
|
+
snippets: snippetsFor(endpoint.url, token),
|
|
125
|
+
status: {
|
|
126
|
+
state: 'listening',
|
|
127
|
+
version,
|
|
128
|
+
startedAt: journal.startedAtIso,
|
|
129
|
+
uptimeMs: Date.now() - Date.parse(journal.startedAtIso),
|
|
130
|
+
totalCalls: journal.totalCalls,
|
|
131
|
+
lastFailure: journal.lastFailure,
|
|
132
|
+
},
|
|
133
|
+
log,
|
|
134
|
+
};
|
|
135
|
+
};
|
|
88
136
|
// The Settings page reads the live config over the authenticated `/api`
|
|
89
137
|
// channel, so the token never enters the served HTML.
|
|
90
|
-
const payload = {
|
|
91
|
-
name: SERVER_NAME,
|
|
92
|
-
url: endpoint.url,
|
|
93
|
-
host,
|
|
94
|
-
port: endpoint.port,
|
|
95
|
-
token,
|
|
96
|
-
updatedAt: state.updatedAt,
|
|
97
|
-
snippets: snippetsFor(endpoint.url, token),
|
|
98
|
-
};
|
|
99
138
|
ctx.inject(['connection'], (connectionCtx) => {
|
|
100
139
|
connectionCtx.connection.fetch.register({
|
|
101
140
|
path: CONFIG_ROUTE_PATH,
|
|
102
141
|
methods: ['GET'],
|
|
103
142
|
requestBody: 'buffered',
|
|
104
|
-
fetch: async () => new Response(JSON.stringify(
|
|
143
|
+
fetch: async () => new Response(JSON.stringify(await buildPayload()), {
|
|
105
144
|
status: 200,
|
|
106
145
|
headers: {
|
|
107
146
|
'content-type': 'application/json; charset=utf-8',
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded request journal for the MCP endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Only the last few tool calls are kept: enough for the Settings page to answer
|
|
5
|
+
* "what just happened, and which conversation did it touch", without turning the
|
|
6
|
+
* plugin into a log store.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-session-mcp/log
|
|
9
|
+
*/
|
|
10
|
+
/** How many calls the journal keeps. */
|
|
11
|
+
export declare const MAX_RECORDS = 5;
|
|
12
|
+
/** One completed tool call. */
|
|
13
|
+
export interface RequestRecord {
|
|
14
|
+
/** Monotonic id, so the page can key rows. */
|
|
15
|
+
readonly id: number;
|
|
16
|
+
/** Wire tool name, for example `session_send`. */
|
|
17
|
+
readonly tool: string;
|
|
18
|
+
/** ISO timestamp of completion. */
|
|
19
|
+
readonly at: string;
|
|
20
|
+
/** Conversation the call addressed, when it addressed one. */
|
|
21
|
+
readonly sessionId: string | null;
|
|
22
|
+
/** Whether the tool answered without `isError`. */
|
|
23
|
+
readonly ok: boolean;
|
|
24
|
+
/** Wall-clock duration in milliseconds. */
|
|
25
|
+
readonly ms: number;
|
|
26
|
+
/** Failure text for a failed call. */
|
|
27
|
+
readonly error?: string;
|
|
28
|
+
}
|
|
29
|
+
/** The failure the page surfaces in its status card. */
|
|
30
|
+
export interface LastFailure {
|
|
31
|
+
readonly at: string;
|
|
32
|
+
readonly tool: string;
|
|
33
|
+
readonly message: string;
|
|
34
|
+
}
|
|
35
|
+
/** The journal itself. */
|
|
36
|
+
export declare class RequestJournal {
|
|
37
|
+
private readonly records;
|
|
38
|
+
private total;
|
|
39
|
+
private failure;
|
|
40
|
+
private readonly startedAt;
|
|
41
|
+
/**
|
|
42
|
+
* Append one completed call, dropping the oldest beyond {@link MAX_RECORDS}.
|
|
43
|
+
* @param entry - everything but the id, which this method assigns.
|
|
44
|
+
*/
|
|
45
|
+
record(entry: Omit<RequestRecord, 'id'>): void;
|
|
46
|
+
/** @returns the newest-first snapshot the Settings page renders. */
|
|
47
|
+
snapshot(): readonly RequestRecord[];
|
|
48
|
+
/** @returns how many calls this endpoint served since it started. */
|
|
49
|
+
get totalCalls(): number;
|
|
50
|
+
/** @returns the most recent failure, or `null` when everything succeeded. */
|
|
51
|
+
get lastFailure(): LastFailure | null;
|
|
52
|
+
/** @returns the ISO timestamp this endpoint started listening. */
|
|
53
|
+
get startedAtIso(): string;
|
|
54
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded request journal for the MCP endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Only the last few tool calls are kept: enough for the Settings page to answer
|
|
5
|
+
* "what just happened, and which conversation did it touch", without turning the
|
|
6
|
+
* plugin into a log store.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-session-mcp/log
|
|
9
|
+
*/
|
|
10
|
+
/** How many calls the journal keeps. */
|
|
11
|
+
export const MAX_RECORDS = 5;
|
|
12
|
+
/** The journal itself. */
|
|
13
|
+
export class RequestJournal {
|
|
14
|
+
records = [];
|
|
15
|
+
total = 0;
|
|
16
|
+
failure = null;
|
|
17
|
+
startedAt = new Date().toISOString();
|
|
18
|
+
/**
|
|
19
|
+
* Append one completed call, dropping the oldest beyond {@link MAX_RECORDS}.
|
|
20
|
+
* @param entry - everything but the id, which this method assigns.
|
|
21
|
+
*/
|
|
22
|
+
record(entry) {
|
|
23
|
+
this.total += 1;
|
|
24
|
+
this.records.unshift({ id: this.total, ...entry });
|
|
25
|
+
if (this.records.length > MAX_RECORDS)
|
|
26
|
+
this.records.length = MAX_RECORDS;
|
|
27
|
+
if (!entry.ok) {
|
|
28
|
+
this.failure = {
|
|
29
|
+
at: entry.at,
|
|
30
|
+
tool: entry.tool,
|
|
31
|
+
message: entry.error ?? 'the tool reported an error',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** @returns the newest-first snapshot the Settings page renders. */
|
|
36
|
+
snapshot() {
|
|
37
|
+
return [...this.records];
|
|
38
|
+
}
|
|
39
|
+
/** @returns how many calls this endpoint served since it started. */
|
|
40
|
+
get totalCalls() {
|
|
41
|
+
return this.total;
|
|
42
|
+
}
|
|
43
|
+
/** @returns the most recent failure, or `null` when everything succeeded. */
|
|
44
|
+
get lastFailure() {
|
|
45
|
+
return this.failure;
|
|
46
|
+
}
|
|
47
|
+
/** @returns the ISO timestamp this endpoint started listening. */
|
|
48
|
+
get startedAtIso() {
|
|
49
|
+
return this.startedAt;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RequestJournal } from './log.js';
|
|
1
2
|
import type { PluginLogger, SessionController } from './types.js';
|
|
2
3
|
/** Path the MCP endpoint answers on. */
|
|
3
4
|
export declare const MCP_PATH = "/mcp";
|
|
@@ -17,6 +18,7 @@ export interface StartEndpointOptions {
|
|
|
17
18
|
readonly port: number;
|
|
18
19
|
readonly version: string;
|
|
19
20
|
readonly logger?: PluginLogger | undefined;
|
|
21
|
+
readonly journal?: RequestJournal | undefined;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Build the web-standard MCP handler for one session controller.
|
|
@@ -24,7 +26,7 @@ export interface StartEndpointOptions {
|
|
|
24
26
|
* @param version - advertised server version.
|
|
25
27
|
* @returns an object exposing `fetch(Request)`.
|
|
26
28
|
*/
|
|
27
|
-
export declare function buildMcpHandler(controller: SessionController, version: string): import("@modelcontextprotocol/server").McpHttpHandler;
|
|
29
|
+
export declare function buildMcpHandler(controller: SessionController, version: string, journal?: RequestJournal): import("@modelcontextprotocol/server").McpHttpHandler;
|
|
28
30
|
/**
|
|
29
31
|
* Start the bearer-authenticated MCP endpoint.
|
|
30
32
|
* @param options - controller, token, host, port, version, logger.
|
package/dist/server.js
CHANGED
|
@@ -32,10 +32,10 @@ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost', '[::1]']);
|
|
|
32
32
|
* @param version - advertised server version.
|
|
33
33
|
* @returns an object exposing `fetch(Request)`.
|
|
34
34
|
*/
|
|
35
|
-
export function buildMcpHandler(controller, version) {
|
|
35
|
+
export function buildMcpHandler(controller, version, journal) {
|
|
36
36
|
return createMcpHandler(() => {
|
|
37
37
|
const server = new McpServer({ name: SERVER_NAME, version }, { instructions: SERVER_INSTRUCTIONS });
|
|
38
|
-
registerTools(server, controller);
|
|
38
|
+
registerTools(server, controller, journal);
|
|
39
39
|
return server;
|
|
40
40
|
});
|
|
41
41
|
}
|
|
@@ -110,8 +110,8 @@ async function bind(server, port, host) {
|
|
|
110
110
|
* @returns the resolved endpoint plus a disposer.
|
|
111
111
|
*/
|
|
112
112
|
export async function startEndpoint(options) {
|
|
113
|
-
const { controller, token, host, port, version, logger } = options;
|
|
114
|
-
const handler = buildMcpHandler(controller, version);
|
|
113
|
+
const { controller, token, host, port, version, logger, journal } = options;
|
|
114
|
+
const handler = buildMcpHandler(controller, version, journal);
|
|
115
115
|
const nodeHandler = toNodeHandler(handler);
|
|
116
116
|
const server = createServer((req, res) => {
|
|
117
117
|
if (!hostIsLoopback(req)) {
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import type { RequestJournal } from './log.js';
|
|
2
3
|
import type { SessionController } from './types.js';
|
|
3
4
|
/**
|
|
4
5
|
* Register the session tools on one MCP server instance.
|
|
5
6
|
* @param server - a fresh `McpServer` for one serving unit.
|
|
6
7
|
* @param controller - the Harness `sessionController` service.
|
|
8
|
+
* @param journal - bounded request journal the Settings page reads.
|
|
7
9
|
*/
|
|
8
|
-
export declare function registerTools(server: McpServer, controller: SessionController): void;
|
|
10
|
+
export declare function registerTools(server: McpServer, controller: SessionController, journal?: RequestJournal): void;
|
package/dist/tools.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* the session log (which is what a `dsh --profile sdk`/`acp` resume of a live
|
|
7
7
|
* session would collide with).
|
|
8
8
|
*
|
|
9
|
+
* Each call is journalled so the Settings page can show what just happened and
|
|
10
|
+
* which conversation it touched.
|
|
11
|
+
*
|
|
9
12
|
* @module dsh-session-mcp/tools
|
|
10
13
|
*/
|
|
11
14
|
import { randomUUID } from 'node:crypto';
|
|
@@ -80,19 +83,66 @@ function fail(error) {
|
|
|
80
83
|
const message = error instanceof Error ? error.message : String(error);
|
|
81
84
|
return { content: [{ type: 'text', text: message }], isError: true };
|
|
82
85
|
}
|
|
86
|
+
/** First text block of a result, for the journal's failure line. */
|
|
87
|
+
function firstText(result) {
|
|
88
|
+
const block = result.content?.[0];
|
|
89
|
+
return typeof block?.text === 'string' ? block.text.slice(0, 400) : 'the tool reported an error';
|
|
90
|
+
}
|
|
91
|
+
/** Read the created conversation id out of a `session_create` result. */
|
|
92
|
+
function createdSessionId(result) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = result.content?.[0]?.text;
|
|
95
|
+
const parsed = JSON.parse(typeof raw === 'string' ? raw : '');
|
|
96
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
97
|
+
const id = parsed.sessionId;
|
|
98
|
+
if (typeof id === 'string')
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* A malformed result is already the caller's error. */
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
83
107
|
/**
|
|
84
108
|
* Register the session tools on one MCP server instance.
|
|
85
109
|
* @param server - a fresh `McpServer` for one serving unit.
|
|
86
110
|
* @param controller - the Harness `sessionController` service.
|
|
111
|
+
* @param journal - bounded request journal the Settings page reads.
|
|
87
112
|
*/
|
|
88
|
-
export function registerTools(server, controller) {
|
|
113
|
+
export function registerTools(server, controller, journal) {
|
|
114
|
+
/**
|
|
115
|
+
* Run one handler and journal the outcome.
|
|
116
|
+
* @param tool - wire tool name.
|
|
117
|
+
* @param run - the handler body.
|
|
118
|
+
* @param fromResult - whether the addressed conversation comes from the result.
|
|
119
|
+
*/
|
|
120
|
+
const tracked = (tool, run, fromResult = false) => {
|
|
121
|
+
return async (args) => {
|
|
122
|
+
const started = Date.now();
|
|
123
|
+
const result = await run(args);
|
|
124
|
+
if (journal !== undefined) {
|
|
125
|
+
const failed = result.isError === true;
|
|
126
|
+
const addressed = typeof args?.sessionId === 'string' ? args.sessionId : fromResult ? createdSessionId(result) : null;
|
|
127
|
+
journal.record({
|
|
128
|
+
tool,
|
|
129
|
+
at: new Date().toISOString(),
|
|
130
|
+
sessionId: addressed,
|
|
131
|
+
ok: !failed,
|
|
132
|
+
ms: Date.now() - started,
|
|
133
|
+
...(failed ? { error: firstText(result) } : {}),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
};
|
|
138
|
+
};
|
|
89
139
|
server.registerTool('sessions_list', {
|
|
90
140
|
title: 'List conversations',
|
|
91
141
|
description: 'List DeepSeek Harness conversations with id, title, working directory, running state and token usage. Call this first to discover the sessionId to read or prompt.',
|
|
92
142
|
inputSchema: z.object({
|
|
93
143
|
limit: z.number().int().min(1).max(500).optional().describe('Maximum rows, newest first. Default 50.'),
|
|
94
144
|
}),
|
|
95
|
-
}, async ({ limit }) => {
|
|
145
|
+
}, tracked('sessions_list', async ({ limit }) => {
|
|
96
146
|
try {
|
|
97
147
|
const value = await controller.list({}, never());
|
|
98
148
|
const rows = [...(value?.items ?? [])]
|
|
@@ -113,7 +163,7 @@ export function registerTools(server, controller) {
|
|
|
113
163
|
catch (error) {
|
|
114
164
|
return fail(error);
|
|
115
165
|
}
|
|
116
|
-
});
|
|
166
|
+
}));
|
|
117
167
|
server.registerTool('session_read', {
|
|
118
168
|
title: 'Read conversation history',
|
|
119
169
|
description: 'Read durable history of one conversation (user, assistant and tool messages). Use beforeSeq to page further back.',
|
|
@@ -122,7 +172,7 @@ export function registerTools(server, controller) {
|
|
|
122
172
|
maxMessages: z.number().int().min(1).max(200).optional().describe('Roughly how many messages. Default 20.'),
|
|
123
173
|
beforeSeq: z.number().int().min(0).optional().describe('Only return events before this sequence number.'),
|
|
124
174
|
}),
|
|
125
|
-
}, async ({ sessionId, maxMessages, beforeSeq }) => {
|
|
175
|
+
}, tracked('session_read', async ({ sessionId, maxMessages, beforeSeq }) => {
|
|
126
176
|
try {
|
|
127
177
|
const rows = await controller.list({}, never());
|
|
128
178
|
const cut = (rows?.items ?? []).find((item) => item.sessionId === sessionId)?.projections?.asOfSeq;
|
|
@@ -144,7 +194,7 @@ export function registerTools(server, controller) {
|
|
|
144
194
|
catch (error) {
|
|
145
195
|
return fail(error);
|
|
146
196
|
}
|
|
147
|
-
});
|
|
197
|
+
}));
|
|
148
198
|
server.registerTool('session_send', {
|
|
149
199
|
title: 'Send a message into a conversation',
|
|
150
200
|
description: 'Send a user message into an existing conversation. The message joins the live chat a human sees and starts a model turn. mode=queue waits for the current turn, mode=steer interrupts into it.',
|
|
@@ -153,7 +203,7 @@ export function registerTools(server, controller) {
|
|
|
153
203
|
text: z.string().min(1).describe('Message text to deliver.'),
|
|
154
204
|
mode: z.enum(['queue', 'steer']).optional().describe('Delivery mode. Default queue.'),
|
|
155
205
|
}),
|
|
156
|
-
}, async ({ sessionId, text, mode }) => {
|
|
206
|
+
}, tracked('session_send', async ({ sessionId, text, mode }) => {
|
|
157
207
|
try {
|
|
158
208
|
if (text.trim().length === 0)
|
|
159
209
|
throw new Error('text must contain non-whitespace content');
|
|
@@ -165,7 +215,7 @@ export function registerTools(server, controller) {
|
|
|
165
215
|
catch (error) {
|
|
166
216
|
return fail(error);
|
|
167
217
|
}
|
|
168
|
-
});
|
|
218
|
+
}));
|
|
169
219
|
server.registerTool('session_create', {
|
|
170
220
|
title: 'Open a new conversation',
|
|
171
221
|
description: 'Open a new blank conversation in the Harness and return its id. Combine with session_send to start work in a fresh chat.',
|
|
@@ -173,7 +223,7 @@ export function registerTools(server, controller) {
|
|
|
173
223
|
cwd: z.string().min(1).optional().describe('Absolute directory the conversation works in.'),
|
|
174
224
|
agentPreset: z.string().min(1).optional().describe('Agent preset name. Omit for the configured default.'),
|
|
175
225
|
}),
|
|
176
|
-
}, async ({ cwd, agentPreset }) => {
|
|
226
|
+
}, tracked('session_create', async ({ cwd, agentPreset }) => {
|
|
177
227
|
try {
|
|
178
228
|
const request = {};
|
|
179
229
|
if (cwd !== undefined)
|
|
@@ -186,14 +236,14 @@ export function registerTools(server, controller) {
|
|
|
186
236
|
catch (error) {
|
|
187
237
|
return fail(error);
|
|
188
238
|
}
|
|
189
|
-
});
|
|
239
|
+
}, true));
|
|
190
240
|
server.registerTool('session_search', {
|
|
191
241
|
title: 'Search conversations',
|
|
192
242
|
description: 'Full-text search across conversations. Works only when the Harness has the session-query index enabled; otherwise the Host reports that search is disabled.',
|
|
193
243
|
inputSchema: z.object({
|
|
194
244
|
query: z.string().min(1).describe('Search text.'),
|
|
195
245
|
}),
|
|
196
|
-
}, async ({ query }) => {
|
|
246
|
+
}, tracked('session_search', async ({ query }) => {
|
|
197
247
|
try {
|
|
198
248
|
const value = await controller.search({ query }, never());
|
|
199
249
|
return ok({ items: value?.items ?? [], hasMore: value?.hasMore === true });
|
|
@@ -201,5 +251,5 @@ export function registerTools(server, controller) {
|
|
|
201
251
|
catch (error) {
|
|
202
252
|
return fail(error);
|
|
203
253
|
}
|
|
204
|
-
});
|
|
254
|
+
}));
|
|
205
255
|
}
|
package/package.json
CHANGED