@nones/dsh-session-mcp 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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: the live endpoint, the bearer token, and
6
- * copy-ready configuration blocks for every client this harness can serve.
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
- g.fetch(CONFIG_URL, { headers: { accept: 'application/json' }, credentials: 'same-origin' })
165
- .then((response) => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${String(response.status)}`)))
166
- .then((data) => {
167
- if (alive)
168
- setState({ status: 'ready', data });
169
- })
170
- .catch((error) => {
171
- if (alive)
172
- setState({ status: 'error', message: String(error?.message ?? error) });
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(payload), {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nones/dsh-session-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for DeepSeek Harness sessions — list, read and prompt live conversations from any MCP client",
5
5
  "type": "module",
6
6
  "license": "MIT",