@nones/dsh-session-mcp 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +171 -2
- package/cordis.patch.yml +11 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +91 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +147 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +205 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.js +11 -0
- package/icon.svg +9 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/package.json +79 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZeiZel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,172 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nones/dsh-session-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP (Model Context Protocol) server for **DeepSeek Harness sessions** — lets Claude Code,
|
|
4
|
+
Claude Desktop, Codex, Cursor and any other MCP client list, read and prompt the
|
|
5
|
+
conversations running in your harness.
|
|
6
|
+
|
|
7
|
+
Ships as a DeepSeek Harness plugin: installing the bundle starts a bearer-authenticated MCP
|
|
8
|
+
endpoint **together with the harness** and writes a ready-to-paste client config. No
|
|
9
|
+
sidecar process to babysit, no port to guess, and no second writer on the session log.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌──────────────────┐ Bearer token ┌──────────────────────────────┐
|
|
13
|
+
│ Claude / Codex │ ────────────────► │ http://127.0.0.1:7801/mcp │
|
|
14
|
+
│ Cursor / other │ ◄──────────────── │ inside the Harness process │
|
|
15
|
+
└──────────────────┘ MCP over HTTP └───────────────┬──────────────┘
|
|
16
|
+
│ in-process
|
|
17
|
+
▼
|
|
18
|
+
┌─────────────────────┐
|
|
19
|
+
│ sessionController │
|
|
20
|
+
│ list / page / … │
|
|
21
|
+
└─────────────────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
DeepSeek Harness keeps its conversations behind a writer lock: a second `dsh` process that
|
|
27
|
+
tries to resume a live session is rejected, and its own HTTP API authenticates with a
|
|
28
|
+
launch token that lives only in memory. This plugin solves both by serving MCP **inside**
|
|
29
|
+
the harness process, where the session controller is already available, and by giving
|
|
30
|
+
clients a stable bearer token instead of a launch secret.
|
|
31
|
+
|
|
32
|
+
`session_send` therefore delivers into the conversation a human is looking at, not into a
|
|
33
|
+
copy of it.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
dsh plugin --profile desktop add @nones/dsh-session-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From a checkout or straight from git:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
dsh plugin --profile desktop add /absolute/path/to/dsh-session-mcp
|
|
45
|
+
bun add github:ZeiZel/dsh-session-mcp
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Restart the harness (or reload the profile) and the endpoint starts with it.
|
|
49
|
+
|
|
50
|
+
## Get your config
|
|
51
|
+
|
|
52
|
+
The harness writes everything to:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
$DSH_HOME/state/dsh-session-mcp.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"url": "http://127.0.0.1:7801/mcp",
|
|
61
|
+
"token": "…",
|
|
62
|
+
"client": {
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"dsh-sessions": {
|
|
65
|
+
"type": "http",
|
|
66
|
+
"url": "http://127.0.0.1:7801/mcp",
|
|
67
|
+
"headers": { "Authorization": "Bearer …" }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Copy the `client` block into your MCP client. Per client:
|
|
75
|
+
|
|
76
|
+
**Claude Code**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude mcp add --transport http dsh-sessions http://127.0.0.1:7801/mcp \
|
|
80
|
+
--header "Authorization: Bearer <token>"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Claude Desktop / Cursor / generic `mcpServers`**
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"mcpServers": {
|
|
88
|
+
"dsh-sessions": {
|
|
89
|
+
"type": "http",
|
|
90
|
+
"url": "http://127.0.0.1:7801/mcp",
|
|
91
|
+
"headers": { "Authorization": "Bearer <token>" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Codex** (`~/.codex/config.toml`)
|
|
98
|
+
|
|
99
|
+
```toml
|
|
100
|
+
[mcp_servers.dsh-sessions]
|
|
101
|
+
url = "http://127.0.0.1:7801/mcp"
|
|
102
|
+
|
|
103
|
+
[mcp_servers.dsh-sessions.http_headers]
|
|
104
|
+
Authorization = "Bearer <token>"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Older clients that only speak the 2025 revisions still work: the endpoint serves the modern
|
|
108
|
+
revision and falls back to stateless serving for 2025-era traffic.
|
|
109
|
+
|
|
110
|
+
## Tools
|
|
111
|
+
|
|
112
|
+
| Tool | Description |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `sessions_list` | Conversations with id, title, working directory, running state, token usage |
|
|
115
|
+
| `session_read` | Durable history (user / assistant / tool), pageable back with `beforeSeq` |
|
|
116
|
+
| `session_send` | Deliver a user message into a conversation (`queue` or `steer`) |
|
|
117
|
+
| `session_create` | Open a new blank conversation and return its id |
|
|
118
|
+
| `session_search` | Full-text search — only when the harness enables the session-query index |
|
|
119
|
+
|
|
120
|
+
## Security
|
|
121
|
+
|
|
122
|
+
- Binds **loopback only** (`127.0.0.1`) and rejects non-loopback `Host` headers.
|
|
123
|
+
- Every request needs `Authorization: Bearer <token>`; the check is constant-time.
|
|
124
|
+
- The token is 32 random bytes, generated once, stored `0600` at
|
|
125
|
+
`$DSH_HOME/state/dsh-session-mcp.json`, and reused across restarts so pasted client
|
|
126
|
+
configs keep working.
|
|
127
|
+
- **Rotate** by deleting that file and restarting the harness. Every client then needs the
|
|
128
|
+
new token.
|
|
129
|
+
- Never expose this endpoint through a proxy or a tunnel: the bearer is the only thing
|
|
130
|
+
between a client and every conversation you have.
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
The plugin row accepts two options (`cordis.patch.yml`):
|
|
135
|
+
|
|
136
|
+
| Option | Default | Meaning |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `port` | `7801` | Preferred loopback port. If taken, an ephemeral port is used and the state file is republished with the real URL. |
|
|
139
|
+
| `host` | `127.0.0.1` | Bind address. |
|
|
140
|
+
|
|
141
|
+
## How it works
|
|
142
|
+
|
|
143
|
+
Written in TypeScript, compiled to `dist/` (committed, so installs need no build step).
|
|
144
|
+
|
|
145
|
+
| Source | Role |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `src/index.ts` | Cordis plugin: reads/creates the bearer, starts the listener, publishes the state file, closes the listener on unload |
|
|
148
|
+
| `src/server.ts` | `node:http` listener with the bearer gate in front of the official SDK's `createMcpHandler` + `toNodeHandler` |
|
|
149
|
+
| `src/tools.ts` | The five tools, backed directly by `ctx.sessionController` |
|
|
150
|
+
| `src/config.ts` | Token persistence, constant-time bearer comparison, client snippet |
|
|
151
|
+
| `src/types.ts` | The narrow structural slice of Harness types this package needs |
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
bun install
|
|
157
|
+
bun run typecheck # tsc --noEmit
|
|
158
|
+
bun run build # tsc -> dist/
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Requires Node ≥ 20. Runtime dependencies: `@modelcontextprotocol/server`,
|
|
162
|
+
`@modelcontextprotocol/node`, `zod`. Dev: `typescript`, `@types/node`.
|
|
163
|
+
|
|
164
|
+
`dist/` is committed so `bun add github:ZeiZel/dsh-session-mcp` and
|
|
165
|
+
`dsh plugin add <dir>` work without a build step; `prepublishOnly` rebuilds it for npm.
|
|
166
|
+
|
|
167
|
+
Releases: push a `v*` tag and the `release` workflow builds, publishes to npm and cuts a
|
|
168
|
+
GitHub release.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT © ZeiZel
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-session-mcp bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# One row: the Host plugin that starts the bearer-authenticated MCP endpoint with
|
|
4
|
+
# the Harness and publishes the client configuration. `port` is the preferred
|
|
5
|
+
# loopback port; if it is taken the plugin binds an ephemeral one and republishes
|
|
6
|
+
# the resolved URL.
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-session-mcp
|
|
9
|
+
name: '@nones/dsh-session-mcp'
|
|
10
|
+
config:
|
|
11
|
+
port: 7801
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Default loopback port for the MCP endpoint. */
|
|
2
|
+
export declare const DEFAULT_PORT = 7801;
|
|
3
|
+
/** Default bind address. Loopback only, on purpose. */
|
|
4
|
+
export declare const DEFAULT_HOST = "127.0.0.1";
|
|
5
|
+
/** Name this server advertises to MCP clients. */
|
|
6
|
+
export declare const SERVER_NAME = "dsh-session-mcp";
|
|
7
|
+
/** Key used in generated client configuration snippets. */
|
|
8
|
+
export declare const CLIENT_KEY = "dsh-sessions";
|
|
9
|
+
/** One `mcpServers` entry as most MCP clients accept it. */
|
|
10
|
+
export interface ClientEntry {
|
|
11
|
+
readonly type: 'http';
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly headers: {
|
|
14
|
+
readonly Authorization: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** The copy-paste fragment written to the endpoint file. */
|
|
18
|
+
export interface ClientConfig {
|
|
19
|
+
readonly mcpServers: Record<string, ClientEntry>;
|
|
20
|
+
}
|
|
21
|
+
/** Persisted endpoint state. */
|
|
22
|
+
export interface EndpointState {
|
|
23
|
+
readonly version: 1;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly url: string;
|
|
26
|
+
readonly host: string;
|
|
27
|
+
readonly port: number;
|
|
28
|
+
readonly token: string;
|
|
29
|
+
readonly createdAt: string;
|
|
30
|
+
readonly updatedAt: string;
|
|
31
|
+
readonly client: ClientConfig;
|
|
32
|
+
}
|
|
33
|
+
/** The subset of stored state read back on restart. */
|
|
34
|
+
export interface StoredState {
|
|
35
|
+
readonly token: string;
|
|
36
|
+
readonly createdAt?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve `$DSH_HOME` the way the launcher does.
|
|
40
|
+
* @returns absolute Harness home.
|
|
41
|
+
*/
|
|
42
|
+
export declare function dshHome(): string;
|
|
43
|
+
/**
|
|
44
|
+
* Absolute path of the endpoint state file.
|
|
45
|
+
* @returns the file the server writes and clients read.
|
|
46
|
+
*/
|
|
47
|
+
export declare function stateFile(): string;
|
|
48
|
+
/**
|
|
49
|
+
* Create a fresh opaque bearer token.
|
|
50
|
+
* @returns 32 random bytes, base64url encoded.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createToken(): string;
|
|
53
|
+
/**
|
|
54
|
+
* Read the persisted endpoint state, if any.
|
|
55
|
+
* @returns the stored state, or `undefined` when absent or unreadable.
|
|
56
|
+
*/
|
|
57
|
+
export declare function readState(): StoredState | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Persist the endpoint state with owner-only permissions.
|
|
60
|
+
* @param state - resolved endpoint facts and the client snippet.
|
|
61
|
+
* @returns the file path written.
|
|
62
|
+
*/
|
|
63
|
+
export declare function writeState(state: EndpointState): string;
|
|
64
|
+
/**
|
|
65
|
+
* Constant-time bearer comparison.
|
|
66
|
+
* @param expected - the stored token.
|
|
67
|
+
* @param header - the raw `Authorization` header value.
|
|
68
|
+
* @returns true only for exactly `Bearer <expected>`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function bearerMatches(expected: string, header: string | undefined): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Build the copy-paste client configuration for this endpoint.
|
|
73
|
+
* @param url - full MCP endpoint URL.
|
|
74
|
+
* @param token - bearer token.
|
|
75
|
+
* @returns an `mcpServers` fragment accepted by most MCP clients.
|
|
76
|
+
*/
|
|
77
|
+
export declare function clientConfig(url: string, token: string): ClientConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint state for the DeepSeek Harness session MCP server.
|
|
3
|
+
*
|
|
4
|
+
* The bearer token and the listening URL must survive restarts: a client config
|
|
5
|
+
* the user pasted into Claude/Codex/Cursor keeps working only while both stay
|
|
6
|
+
* stable. The token is therefore generated once, stored with 0600 permissions,
|
|
7
|
+
* and reused; only the URL changes when the port has to fall back.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-session-mcp/config
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
/** Default loopback port for the MCP endpoint. */
|
|
16
|
+
export const DEFAULT_PORT = 7801;
|
|
17
|
+
/** Default bind address. Loopback only, on purpose. */
|
|
18
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
19
|
+
/** Name this server advertises to MCP clients. */
|
|
20
|
+
export const SERVER_NAME = 'dsh-session-mcp';
|
|
21
|
+
/** Key used in generated client configuration snippets. */
|
|
22
|
+
export const CLIENT_KEY = 'dsh-sessions';
|
|
23
|
+
/**
|
|
24
|
+
* Resolve `$DSH_HOME` the way the launcher does.
|
|
25
|
+
* @returns absolute Harness home.
|
|
26
|
+
*/
|
|
27
|
+
export function dshHome() {
|
|
28
|
+
const fromEnv = process.env.DSH_HOME;
|
|
29
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0)
|
|
30
|
+
return fromEnv;
|
|
31
|
+
return join(homedir(), '.dsh');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Absolute path of the endpoint state file.
|
|
35
|
+
* @returns the file the server writes and clients read.
|
|
36
|
+
*/
|
|
37
|
+
export function stateFile() {
|
|
38
|
+
return join(dshHome(), 'state', 'dsh-session-mcp.json');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create a fresh opaque bearer token.
|
|
42
|
+
* @returns 32 random bytes, base64url encoded.
|
|
43
|
+
*/
|
|
44
|
+
export function createToken() {
|
|
45
|
+
return randomBytes(32).toString('base64url');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read the persisted endpoint state, if any.
|
|
49
|
+
* @returns the stored state, or `undefined` when absent or unreadable.
|
|
50
|
+
*/
|
|
51
|
+
export function readState() {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(stateFile(), 'utf8'));
|
|
54
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
55
|
+
return undefined;
|
|
56
|
+
const candidate = parsed;
|
|
57
|
+
if (typeof candidate.token !== 'string' || candidate.token.length < 16)
|
|
58
|
+
return undefined;
|
|
59
|
+
return {
|
|
60
|
+
token: candidate.token,
|
|
61
|
+
...(typeof candidate.createdAt === 'string' ? { createdAt: candidate.createdAt } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Persist the endpoint state with owner-only permissions.
|
|
70
|
+
* @param state - resolved endpoint facts and the client snippet.
|
|
71
|
+
* @returns the file path written.
|
|
72
|
+
*/
|
|
73
|
+
export function writeState(state) {
|
|
74
|
+
mkdirSync(join(dshHome(), 'state'), { recursive: true });
|
|
75
|
+
const file = stateFile();
|
|
76
|
+
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
77
|
+
try {
|
|
78
|
+
chmodSync(file, 0o600);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* A pre-existing file keeps its mode; the token is local-only either way. */
|
|
82
|
+
}
|
|
83
|
+
return file;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Constant-time bearer comparison.
|
|
87
|
+
* @param expected - the stored token.
|
|
88
|
+
* @param header - the raw `Authorization` header value.
|
|
89
|
+
* @returns true only for exactly `Bearer <expected>`.
|
|
90
|
+
*/
|
|
91
|
+
export function bearerMatches(expected, header) {
|
|
92
|
+
if (typeof header !== 'string')
|
|
93
|
+
return false;
|
|
94
|
+
const match = /^Bearer\s+(\S+)$/iu.exec(header.trim());
|
|
95
|
+
if (match === null)
|
|
96
|
+
return false;
|
|
97
|
+
const presented = match[1];
|
|
98
|
+
if (presented === undefined)
|
|
99
|
+
return false;
|
|
100
|
+
const actual = Buffer.from(presented, 'utf8');
|
|
101
|
+
const wanted = Buffer.from(expected, 'utf8');
|
|
102
|
+
if (actual.byteLength !== wanted.byteLength)
|
|
103
|
+
return false;
|
|
104
|
+
return timingSafeEqual(actual, wanted);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Build the copy-paste client configuration for this endpoint.
|
|
108
|
+
* @param url - full MCP endpoint URL.
|
|
109
|
+
* @param token - bearer token.
|
|
110
|
+
* @returns an `mcpServers` fragment accepted by most MCP clients.
|
|
111
|
+
*/
|
|
112
|
+
export function clientConfig(url, token) {
|
|
113
|
+
return {
|
|
114
|
+
mcpServers: {
|
|
115
|
+
[CLIENT_KEY]: {
|
|
116
|
+
type: 'http',
|
|
117
|
+
url,
|
|
118
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PluginContext } from './types.js';
|
|
2
|
+
/** Stable Cordis plugin name. */
|
|
3
|
+
export declare const name = "dsh-session-mcp";
|
|
4
|
+
/** The session controller is the only capability this plugin needs. */
|
|
5
|
+
export declare const inject: readonly ['sessionController'];
|
|
6
|
+
/** Row configuration accepted by the bundle patch. */
|
|
7
|
+
export interface Config {
|
|
8
|
+
/** Preferred loopback port; an ephemeral one is used when it is taken. */
|
|
9
|
+
readonly port?: number;
|
|
10
|
+
/** Bind address. Defaults to `127.0.0.1`. */
|
|
11
|
+
readonly host?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Mount the endpoint.
|
|
15
|
+
* @param ctx - Host context carrying `sessionController`.
|
|
16
|
+
* @param config - Loader row config: `{ port?, host? }`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function apply(ctx: PluginContext, config?: Config): Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-mcp — Host plugin.
|
|
3
|
+
*
|
|
4
|
+
* Starting this plugin starts the MCP endpoint with the Harness: it binds a
|
|
5
|
+
* loopback listener, requires the bearer token stored for this Harness home,
|
|
6
|
+
* and serves the session tools in-process through `ctx.sessionController`.
|
|
7
|
+
*
|
|
8
|
+
* The bearer token is generated once and reused, so a client configuration the
|
|
9
|
+
* user pasted into Claude/Codex/Cursor keeps working across restarts. The
|
|
10
|
+
* resolved endpoint and a ready `mcpServers` fragment are written to
|
|
11
|
+
* `$DSH_HOME/state/dsh-session-mcp.json` on every start.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-session-mcp
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { clientConfig, createToken, DEFAULT_HOST, DEFAULT_PORT, readState, SERVER_NAME, writeState, } from './config.js';
|
|
17
|
+
import { startEndpoint } from './server.js';
|
|
18
|
+
/** Stable Cordis plugin name. */
|
|
19
|
+
export const name = 'dsh-session-mcp';
|
|
20
|
+
/** The session controller is the only capability this plugin needs. */
|
|
21
|
+
export const inject = ['sessionController'];
|
|
22
|
+
/**
|
|
23
|
+
* Read this package's version without assuming a checkout layout.
|
|
24
|
+
* @returns the `version` field of the package manifest.
|
|
25
|
+
*/
|
|
26
|
+
function packageVersion() {
|
|
27
|
+
try {
|
|
28
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
29
|
+
if (manifest !== null && typeof manifest === 'object') {
|
|
30
|
+
const version = manifest.version;
|
|
31
|
+
if (typeof version === 'string')
|
|
32
|
+
return version;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* A missing manifest only affects the advertised version string. */
|
|
37
|
+
}
|
|
38
|
+
return '0.0.0';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the row config with defaults and type checks.
|
|
42
|
+
* @param config - raw Loader row config.
|
|
43
|
+
* @returns the effective `{ port, host }`.
|
|
44
|
+
*/
|
|
45
|
+
function settingsOf(config) {
|
|
46
|
+
const port = config?.port ?? DEFAULT_PORT;
|
|
47
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
48
|
+
throw new RangeError(`dsh-session-mcp: port ${String(port)} is out of range`);
|
|
49
|
+
}
|
|
50
|
+
const host = config?.host !== undefined && config.host.length > 0 ? config.host : DEFAULT_HOST;
|
|
51
|
+
return { port, host };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Mount the endpoint.
|
|
55
|
+
* @param ctx - Host context carrying `sessionController`.
|
|
56
|
+
* @param config - Loader row config: `{ port?, host? }`.
|
|
57
|
+
*/
|
|
58
|
+
export async function apply(ctx, config) {
|
|
59
|
+
const { port, host } = settingsOf(config);
|
|
60
|
+
const version = packageVersion();
|
|
61
|
+
const stored = readState();
|
|
62
|
+
const token = stored?.token ?? createToken();
|
|
63
|
+
const createdAt = stored?.createdAt ?? new Date().toISOString();
|
|
64
|
+
const endpoint = await startEndpoint({
|
|
65
|
+
controller: ctx.sessionController,
|
|
66
|
+
token,
|
|
67
|
+
host,
|
|
68
|
+
port,
|
|
69
|
+
version,
|
|
70
|
+
logger: ctx.logger,
|
|
71
|
+
});
|
|
72
|
+
const state = {
|
|
73
|
+
version: 1,
|
|
74
|
+
name: SERVER_NAME,
|
|
75
|
+
url: endpoint.url,
|
|
76
|
+
host,
|
|
77
|
+
port: endpoint.port,
|
|
78
|
+
token,
|
|
79
|
+
createdAt,
|
|
80
|
+
updatedAt: new Date().toISOString(),
|
|
81
|
+
client: clientConfig(endpoint.url, token),
|
|
82
|
+
};
|
|
83
|
+
const file = writeState(state);
|
|
84
|
+
ctx.logger?.info?.(`dsh-session-mcp: MCP endpoint listening at ${endpoint.url}`);
|
|
85
|
+
ctx.logger?.info?.(`dsh-session-mcp: bearer token and client config in ${file}`);
|
|
86
|
+
console.log(`dsh-session-mcp: ${endpoint.url} (bearer in ${file})`);
|
|
87
|
+
console.log(JSON.stringify(state.client, null, 2));
|
|
88
|
+
ctx.effect(() => () => {
|
|
89
|
+
void endpoint.close();
|
|
90
|
+
}, 'dsh-session-mcp: endpoint lifetime');
|
|
91
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PluginLogger, SessionController } from './types.js';
|
|
2
|
+
/** Path the MCP endpoint answers on. */
|
|
3
|
+
export declare const MCP_PATH = "/mcp";
|
|
4
|
+
/** Description every client receives during `initialize`. */
|
|
5
|
+
export declare const SERVER_INSTRUCTIONS: string;
|
|
6
|
+
/** A running endpoint. */
|
|
7
|
+
export interface RunningEndpoint {
|
|
8
|
+
readonly url: string;
|
|
9
|
+
readonly port: number;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** Options accepted by {@link startEndpoint}. */
|
|
13
|
+
export interface StartEndpointOptions {
|
|
14
|
+
readonly controller: SessionController;
|
|
15
|
+
readonly token: string;
|
|
16
|
+
readonly host: string;
|
|
17
|
+
readonly port: number;
|
|
18
|
+
readonly version: string;
|
|
19
|
+
readonly logger?: PluginLogger | undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the web-standard MCP handler for one session controller.
|
|
23
|
+
* @param controller - Harness `sessionController` service.
|
|
24
|
+
* @param version - advertised server version.
|
|
25
|
+
* @returns an object exposing `fetch(Request)`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildMcpHandler(controller: SessionController, version: string): import("@modelcontextprotocol/server").McpHttpHandler;
|
|
28
|
+
/**
|
|
29
|
+
* Start the bearer-authenticated MCP endpoint.
|
|
30
|
+
* @param options - controller, token, host, port, version, logger.
|
|
31
|
+
* @returns the resolved endpoint plus a disposer.
|
|
32
|
+
*/
|
|
33
|
+
export declare function startEndpoint(options: StartEndpointOptions): Promise<RunningEndpoint>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-authenticated HTTP MCP endpoint.
|
|
3
|
+
*
|
|
4
|
+
* The MCP protocol work is the official SDK's: `createMcpHandler` serves the
|
|
5
|
+
* modern revision from a per-request server factory and falls back to stateless
|
|
6
|
+
* serving for 2025-era clients, so older MCP clients keep working. This module
|
|
7
|
+
* owns only what the SDK deliberately leaves to the caller: the loopback
|
|
8
|
+
* listener, the path, and the bearer check.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-session-mcp/server
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { toNodeHandler } from '@modelcontextprotocol/node';
|
|
14
|
+
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
|
15
|
+
import { bearerMatches, SERVER_NAME } from './config.js';
|
|
16
|
+
import { registerTools } from './tools.js';
|
|
17
|
+
/** Path the MCP endpoint answers on. */
|
|
18
|
+
export const MCP_PATH = '/mcp';
|
|
19
|
+
/** Description every client receives during `initialize`. */
|
|
20
|
+
export const SERVER_INSTRUCTIONS = [
|
|
21
|
+
'This server exposes one running DeepSeek Harness instance: its conversations, their durable history,',
|
|
22
|
+
'and the ability to deliver a new user message into a live conversation.',
|
|
23
|
+
'Start with sessions_list to discover conversation ids, then session_read to inspect history,',
|
|
24
|
+
'or session_send to deliver a message. session_send joins the chat a human is watching and starts a',
|
|
25
|
+
'model turn, so send complete, deliberate messages rather than fragments.',
|
|
26
|
+
].join(' ');
|
|
27
|
+
/** Loopback hostnames accepted in the Host header. */
|
|
28
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost', '[::1]']);
|
|
29
|
+
/**
|
|
30
|
+
* Build the web-standard MCP handler for one session controller.
|
|
31
|
+
* @param controller - Harness `sessionController` service.
|
|
32
|
+
* @param version - advertised server version.
|
|
33
|
+
* @returns an object exposing `fetch(Request)`.
|
|
34
|
+
*/
|
|
35
|
+
export function buildMcpHandler(controller, version) {
|
|
36
|
+
return createMcpHandler(() => {
|
|
37
|
+
const server = new McpServer({ name: SERVER_NAME, version }, { instructions: SERVER_INSTRUCTIONS });
|
|
38
|
+
registerTools(server, controller);
|
|
39
|
+
return server;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether a request's Host header names this machine.
|
|
44
|
+
* @param req - incoming request.
|
|
45
|
+
* @returns false for anything that is not loopback.
|
|
46
|
+
*/
|
|
47
|
+
function hostIsLoopback(req) {
|
|
48
|
+
const header = req.headers.host;
|
|
49
|
+
if (typeof header !== 'string')
|
|
50
|
+
return false;
|
|
51
|
+
const name = header.replace(/:\d+$/u, '');
|
|
52
|
+
return LOOPBACK.has(name.toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Write one small JSON response.
|
|
56
|
+
* @param res - node response.
|
|
57
|
+
* @param status - HTTP status.
|
|
58
|
+
* @param body - JSON-serializable body.
|
|
59
|
+
* @param headers - extra headers.
|
|
60
|
+
*/
|
|
61
|
+
function json(res, status, body, headers = {}) {
|
|
62
|
+
const payload = JSON.stringify(body);
|
|
63
|
+
res.writeHead(status, {
|
|
64
|
+
'content-type': 'application/json; charset=utf-8',
|
|
65
|
+
'content-length': Buffer.byteLength(payload),
|
|
66
|
+
'cache-control': 'no-store',
|
|
67
|
+
...headers,
|
|
68
|
+
});
|
|
69
|
+
res.end(payload);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Bind a listener, falling back to an ephemeral port when the wanted one is taken.
|
|
73
|
+
* @param server - node http server.
|
|
74
|
+
* @param port - preferred port, `0` for ephemeral.
|
|
75
|
+
* @param host - bind address.
|
|
76
|
+
* @returns the bound port and whether the fallback ran.
|
|
77
|
+
*/
|
|
78
|
+
async function bind(server, port, host) {
|
|
79
|
+
const attempt = (wanted) => new Promise((resolve, reject) => {
|
|
80
|
+
const onError = (error) => {
|
|
81
|
+
server.off('listening', onListening);
|
|
82
|
+
reject(error);
|
|
83
|
+
};
|
|
84
|
+
const onListening = () => {
|
|
85
|
+
server.off('error', onError);
|
|
86
|
+
const address = server.address();
|
|
87
|
+
if (address === null || typeof address === 'string') {
|
|
88
|
+
reject(new Error('dsh-session-mcp: the listener bound without an address'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
resolve(address.port);
|
|
92
|
+
};
|
|
93
|
+
server.once('error', onError);
|
|
94
|
+
server.once('listening', onListening);
|
|
95
|
+
server.listen(wanted, host);
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
return { port: await attempt(port), fellBack: false };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
const code = error?.code;
|
|
102
|
+
if (port === 0 || code !== 'EADDRINUSE')
|
|
103
|
+
throw error;
|
|
104
|
+
return { port: await attempt(0), fellBack: true };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Start the bearer-authenticated MCP endpoint.
|
|
109
|
+
* @param options - controller, token, host, port, version, logger.
|
|
110
|
+
* @returns the resolved endpoint plus a disposer.
|
|
111
|
+
*/
|
|
112
|
+
export async function startEndpoint(options) {
|
|
113
|
+
const { controller, token, host, port, version, logger } = options;
|
|
114
|
+
const handler = buildMcpHandler(controller, version);
|
|
115
|
+
const nodeHandler = toNodeHandler(handler);
|
|
116
|
+
const server = createServer((req, res) => {
|
|
117
|
+
if (!hostIsLoopback(req)) {
|
|
118
|
+
json(res, 403, { error: 'forbidden', message: 'this endpoint accepts loopback hosts only' });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!bearerMatches(token, req.headers.authorization)) {
|
|
122
|
+
json(res, 401, { error: 'unauthorized', message: 'send Authorization: Bearer <token> from the Harness endpoint file' }, { 'www-authenticate': `Bearer realm="${SERVER_NAME}"` });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const path = (req.url ?? '').split('?')[0];
|
|
126
|
+
if (path !== MCP_PATH && path !== `${MCP_PATH}/`) {
|
|
127
|
+
json(res, 404, { error: 'not_found', message: `the MCP endpoint lives at ${MCP_PATH}` });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
void nodeHandler(req, res);
|
|
131
|
+
});
|
|
132
|
+
const bound = await bind(server, port, host);
|
|
133
|
+
if (bound.fellBack) {
|
|
134
|
+
logger?.warn?.(`dsh-session-mcp: port ${port} is in use; bound an ephemeral port ${bound.port} instead`);
|
|
135
|
+
}
|
|
136
|
+
const url = `http://${host === '::1' ? '[::1]' : host}:${bound.port}${MCP_PATH}`;
|
|
137
|
+
return {
|
|
138
|
+
url,
|
|
139
|
+
port: bound.port,
|
|
140
|
+
async close() {
|
|
141
|
+
await new Promise((resolve) => {
|
|
142
|
+
server.close(() => resolve());
|
|
143
|
+
server.closeAllConnections?.();
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import type { SessionController } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Register the session tools on one MCP server instance.
|
|
5
|
+
* @param server - a fresh `McpServer` for one serving unit.
|
|
6
|
+
* @param controller - the Harness `sessionController` service.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerTools(server: McpServer, controller: SessionController): void;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP tool surface over a DeepSeek Harness session controller.
|
|
3
|
+
*
|
|
4
|
+
* Every tool answers from `ctx.sessionController` in-process, as the owner, so
|
|
5
|
+
* a prompt lands in the live conversation instead of opening a second writer on
|
|
6
|
+
* the session log (which is what a `dsh --profile sdk`/`acp` resume of a live
|
|
7
|
+
* session would collide with).
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-session-mcp/tools
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
/** Longest single message body returned to a client. */
|
|
14
|
+
const MAX_TEXT_CHARS = 4000;
|
|
15
|
+
/** A signal that never aborts; the controller only needs a cancellation handle. */
|
|
16
|
+
function never() {
|
|
17
|
+
return new AbortController().signal;
|
|
18
|
+
}
|
|
19
|
+
/** Collapse one content-block array into readable text. */
|
|
20
|
+
function blocksToText(blocks) {
|
|
21
|
+
if (typeof blocks === 'string')
|
|
22
|
+
return blocks;
|
|
23
|
+
if (!Array.isArray(blocks))
|
|
24
|
+
return '';
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (const block of blocks) {
|
|
27
|
+
if (block === null || typeof block !== 'object')
|
|
28
|
+
continue;
|
|
29
|
+
const candidate = block;
|
|
30
|
+
if (candidate.type === 'text' && typeof candidate.text === 'string')
|
|
31
|
+
parts.push(candidate.text);
|
|
32
|
+
else if (candidate.type === 'tool-call') {
|
|
33
|
+
parts.push(`[tool ${String(candidate.name ?? '?')} ${String(candidate.arguments ?? '').slice(0, 400)}]`);
|
|
34
|
+
}
|
|
35
|
+
else if (candidate.type === 'image')
|
|
36
|
+
parts.push('[image]');
|
|
37
|
+
}
|
|
38
|
+
return parts.join('\n').trim();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reduce one durable Session event to a compact message.
|
|
42
|
+
* @param record - one entry of `sessionController.page().records`.
|
|
43
|
+
* @returns a message, or `null` for events without conversational content.
|
|
44
|
+
*/
|
|
45
|
+
function reduceRecord(record) {
|
|
46
|
+
const event = (record.event ?? record);
|
|
47
|
+
if (event === null || typeof event !== 'object')
|
|
48
|
+
return null;
|
|
49
|
+
const data = (event.data ?? {});
|
|
50
|
+
const message = (data.message ?? {});
|
|
51
|
+
const out = { seq: event.seq, type: event.type, text: '' };
|
|
52
|
+
if (event.time !== undefined)
|
|
53
|
+
out.time = event.time;
|
|
54
|
+
if (event.type === 'user/message' || event.type === 'assistant/message') {
|
|
55
|
+
out.role = message.role ?? (event.type === 'user/message' ? 'user' : 'assistant');
|
|
56
|
+
out.text = blocksToText(message.content);
|
|
57
|
+
}
|
|
58
|
+
else if (event.type === 'tool/call') {
|
|
59
|
+
out.role = 'tool';
|
|
60
|
+
out.name = typeof data.name === 'string' ? data.name : undefined;
|
|
61
|
+
out.text = String(data.arguments ?? '');
|
|
62
|
+
}
|
|
63
|
+
else if (event.type === 'tool/result') {
|
|
64
|
+
out.role = 'tool';
|
|
65
|
+
out.text = blocksToText(message.content);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (out.text.length > MAX_TEXT_CHARS)
|
|
71
|
+
out.text = `${out.text.slice(0, MAX_TEXT_CHARS)}… [truncated]`;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Wrap one value as an MCP text result. */
|
|
75
|
+
function ok(value) {
|
|
76
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
77
|
+
}
|
|
78
|
+
/** Wrap one failure as an MCP error result. */
|
|
79
|
+
function fail(error) {
|
|
80
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
81
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Register the session tools on one MCP server instance.
|
|
85
|
+
* @param server - a fresh `McpServer` for one serving unit.
|
|
86
|
+
* @param controller - the Harness `sessionController` service.
|
|
87
|
+
*/
|
|
88
|
+
export function registerTools(server, controller) {
|
|
89
|
+
server.registerTool('sessions_list', {
|
|
90
|
+
title: 'List conversations',
|
|
91
|
+
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
|
+
inputSchema: z.object({
|
|
93
|
+
limit: z.number().int().min(1).max(500).optional().describe('Maximum rows, newest first. Default 50.'),
|
|
94
|
+
}),
|
|
95
|
+
}, async ({ limit }) => {
|
|
96
|
+
try {
|
|
97
|
+
const value = await controller.list({}, never());
|
|
98
|
+
const rows = [...(value?.items ?? [])]
|
|
99
|
+
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
100
|
+
.slice(0, limit ?? 50)
|
|
101
|
+
.map((item) => ({
|
|
102
|
+
sessionId: item.sessionId,
|
|
103
|
+
title: item.projections?.values?.title ?? null,
|
|
104
|
+
cwd: item.cwd ?? null,
|
|
105
|
+
updatedAt: item.updatedAt === undefined ? null : new Date(item.updatedAt).toISOString(),
|
|
106
|
+
running: item.running === true,
|
|
107
|
+
blank: item.blank === true,
|
|
108
|
+
agentAvailable: item.agentAvailable === true,
|
|
109
|
+
tokens: item.projections?.values?.tokenUsage ?? null,
|
|
110
|
+
}));
|
|
111
|
+
return ok({ count: rows.length, sessions: rows });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return fail(error);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
server.registerTool('session_read', {
|
|
118
|
+
title: 'Read conversation history',
|
|
119
|
+
description: 'Read durable history of one conversation (user, assistant and tool messages). Use beforeSeq to page further back.',
|
|
120
|
+
inputSchema: z.object({
|
|
121
|
+
sessionId: z.string().min(1).describe('Conversation id from sessions_list.'),
|
|
122
|
+
maxMessages: z.number().int().min(1).max(200).optional().describe('Roughly how many messages. Default 20.'),
|
|
123
|
+
beforeSeq: z.number().int().min(0).optional().describe('Only return events before this sequence number.'),
|
|
124
|
+
}),
|
|
125
|
+
}, async ({ sessionId, maxMessages, beforeSeq }) => {
|
|
126
|
+
try {
|
|
127
|
+
const rows = await controller.list({}, never());
|
|
128
|
+
const cut = (rows?.items ?? []).find((item) => item.sessionId === sessionId)?.projections?.asOfSeq;
|
|
129
|
+
const projections = typeof cut === 'number' ? undefined : await controller.projections({ sessionId }, never());
|
|
130
|
+
const throughSeq = typeof cut === 'number' ? cut : projections?.asOfSeq;
|
|
131
|
+
if (typeof throughSeq !== 'number')
|
|
132
|
+
throw new Error(`Session ${sessionId} has no readable log cut`);
|
|
133
|
+
const request = {
|
|
134
|
+
address: { kind: 'session', sessionId },
|
|
135
|
+
throughSeq,
|
|
136
|
+
maxMessages: maxMessages ?? 20,
|
|
137
|
+
};
|
|
138
|
+
if (beforeSeq !== undefined)
|
|
139
|
+
request.beforeSeq = beforeSeq;
|
|
140
|
+
const page = await controller.page(request, never());
|
|
141
|
+
const messages = (page?.records ?? []).map(reduceRecord).filter((entry) => entry !== null);
|
|
142
|
+
return ok({ sessionId, throughSeq, hasMore: page?.hasMore === true, messages });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return fail(error);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
server.registerTool('session_send', {
|
|
149
|
+
title: 'Send a message into a conversation',
|
|
150
|
+
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.',
|
|
151
|
+
inputSchema: z.object({
|
|
152
|
+
sessionId: z.string().min(1).describe('Conversation id from sessions_list.'),
|
|
153
|
+
text: z.string().min(1).describe('Message text to deliver.'),
|
|
154
|
+
mode: z.enum(['queue', 'steer']).optional().describe('Delivery mode. Default queue.'),
|
|
155
|
+
}),
|
|
156
|
+
}, async ({ sessionId, text, mode }) => {
|
|
157
|
+
try {
|
|
158
|
+
if (text.trim().length === 0)
|
|
159
|
+
throw new Error('text must contain non-whitespace content');
|
|
160
|
+
const requestId = randomUUID();
|
|
161
|
+
const delivery = mode ?? 'queue';
|
|
162
|
+
const value = await controller.prompt({ requestId, sessionId, mode: delivery, content: [{ type: 'text', text }] }, never());
|
|
163
|
+
return ok({ sessionId, requestId, mode: delivery, accepted: value?.accepted === true });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
return fail(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
server.registerTool('session_create', {
|
|
170
|
+
title: 'Open a new conversation',
|
|
171
|
+
description: 'Open a new blank conversation in the Harness and return its id. Combine with session_send to start work in a fresh chat.',
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
cwd: z.string().min(1).optional().describe('Absolute directory the conversation works in.'),
|
|
174
|
+
agentPreset: z.string().min(1).optional().describe('Agent preset name. Omit for the configured default.'),
|
|
175
|
+
}),
|
|
176
|
+
}, async ({ cwd, agentPreset }) => {
|
|
177
|
+
try {
|
|
178
|
+
const request = {};
|
|
179
|
+
if (cwd !== undefined)
|
|
180
|
+
request.cwd = cwd;
|
|
181
|
+
if (agentPreset !== undefined)
|
|
182
|
+
request.agentPreset = agentPreset;
|
|
183
|
+
const value = await controller.create(request);
|
|
184
|
+
return ok({ sessionId: value?.sessionId ?? null, agentPreset: value?.agentPreset ?? null });
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
return fail(error);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
server.registerTool('session_search', {
|
|
191
|
+
title: 'Search conversations',
|
|
192
|
+
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
|
+
inputSchema: z.object({
|
|
194
|
+
query: z.string().min(1).describe('Search text.'),
|
|
195
|
+
}),
|
|
196
|
+
}, async ({ query }) => {
|
|
197
|
+
try {
|
|
198
|
+
const value = await controller.search({ query }, never());
|
|
199
|
+
return ok({ items: value?.items ?? [], hasMore: value?.hasMore === true });
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
return fail(error);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrow slice of DeepSeek Harness types this package depends on.
|
|
3
|
+
*
|
|
4
|
+
* The plugin ships as a standalone npm package, so it does not import the
|
|
5
|
+
* harness's own type packages (which resolve only inside a Harness profile).
|
|
6
|
+
* These structural declarations mirror the parts of `ctx.sessionController`
|
|
7
|
+
* and the Cordis plugin context the bridge actually uses.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-session-mcp/types
|
|
10
|
+
*/
|
|
11
|
+
/** One conversation row as `sessionController.list` reports it. */
|
|
12
|
+
export interface SessionSummary {
|
|
13
|
+
readonly sessionId: string;
|
|
14
|
+
readonly updatedAt?: number;
|
|
15
|
+
readonly cwd?: string;
|
|
16
|
+
readonly running?: boolean;
|
|
17
|
+
readonly blank?: boolean;
|
|
18
|
+
readonly agentAvailable?: boolean;
|
|
19
|
+
readonly projections?: {
|
|
20
|
+
readonly asOfSeq?: number;
|
|
21
|
+
readonly values?: {
|
|
22
|
+
readonly title?: string | null;
|
|
23
|
+
readonly tokenUsage?: unknown;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** A durable conversation event envelope. */
|
|
28
|
+
export interface SessionEventRecord {
|
|
29
|
+
readonly type?: string;
|
|
30
|
+
readonly event?: {
|
|
31
|
+
readonly type?: string;
|
|
32
|
+
readonly seq?: number;
|
|
33
|
+
readonly time?: number;
|
|
34
|
+
readonly data?: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** One page of durable history. */
|
|
38
|
+
export interface SessionPage {
|
|
39
|
+
readonly records?: readonly SessionEventRecord[];
|
|
40
|
+
readonly hasMore?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** One message part accepted by `prompt`. */
|
|
43
|
+
export interface PromptContentPart {
|
|
44
|
+
readonly type: 'text';
|
|
45
|
+
readonly text: string;
|
|
46
|
+
}
|
|
47
|
+
/** The controller surface used by the MCP tools. */
|
|
48
|
+
export interface SessionController {
|
|
49
|
+
list(request: Record<string, never>, signal: AbortSignal): Promise<{
|
|
50
|
+
items?: readonly SessionSummary[];
|
|
51
|
+
}>;
|
|
52
|
+
page(request: Record<string, unknown>, signal: AbortSignal): Promise<SessionPage>;
|
|
53
|
+
prompt(request: Record<string, unknown>, signal: AbortSignal): Promise<{
|
|
54
|
+
accepted?: boolean;
|
|
55
|
+
}>;
|
|
56
|
+
create(request: Record<string, unknown>): Promise<{
|
|
57
|
+
sessionId?: string;
|
|
58
|
+
agentPreset?: string;
|
|
59
|
+
}>;
|
|
60
|
+
search(request: {
|
|
61
|
+
query: string;
|
|
62
|
+
}, signal: AbortSignal): Promise<{
|
|
63
|
+
items?: unknown[];
|
|
64
|
+
hasMore?: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
projections(request: {
|
|
67
|
+
sessionId: string;
|
|
68
|
+
}, signal: AbortSignal): Promise<{
|
|
69
|
+
asOfSeq?: number;
|
|
70
|
+
} | null>;
|
|
71
|
+
}
|
|
72
|
+
/** Minimal logger surface shared by Cordis and this package's tests. */
|
|
73
|
+
export interface PluginLogger {
|
|
74
|
+
info?(message: string): void;
|
|
75
|
+
warn?(message: string): void;
|
|
76
|
+
}
|
|
77
|
+
/** Minimal Cordis plugin context used by {@link apply}. */
|
|
78
|
+
export interface PluginContext {
|
|
79
|
+
readonly sessionController: SessionController;
|
|
80
|
+
readonly logger?: PluginLogger;
|
|
81
|
+
effect(setup: () => () => void, label?: string): void;
|
|
82
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrow slice of DeepSeek Harness types this package depends on.
|
|
3
|
+
*
|
|
4
|
+
* The plugin ships as a standalone npm package, so it does not import the
|
|
5
|
+
* harness's own type packages (which resolve only inside a Harness profile).
|
|
6
|
+
* These structural declarations mirror the parts of `ctx.sessionController`
|
|
7
|
+
* and the Cordis plugin context the bridge actually uses.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-session-mcp/types
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="DSH Session MCP Bridge">
|
|
2
|
+
<g fill="none" stroke="#2f6f4e" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
|
3
|
+
<rect x="6" y="14" width="22" height="36" rx="5"/>
|
|
4
|
+
<rect x="36" y="14" width="22" height="36" rx="5"/>
|
|
5
|
+
<path d="M28 26h8M28 38h8"/>
|
|
6
|
+
<path d="M14 24h6M14 32h6M14 40h6"/>
|
|
7
|
+
<path d="M44 24h6M44 32h6M44 40h6"/>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|
package/locale/en.json
ADDED
package/locale/ru.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nones/dsh-session-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "MCP server for DeepSeek Harness sessions — list, read and prompt live conversations from any MCP client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ZeiZel <valera2003lvov@yandex.ru>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ZeiZel/dsh-session-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/ZeiZel/dsh-session-mcp#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ZeiZel/dsh-session-mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"deepseek-harness",
|
|
18
|
+
"dsh",
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"ai-agents",
|
|
22
|
+
"sessions",
|
|
23
|
+
"claude",
|
|
24
|
+
"codex",
|
|
25
|
+
"cursor",
|
|
26
|
+
"plugin",
|
|
27
|
+
"bundle"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./server": {
|
|
40
|
+
"types": "./dist/server.d.ts",
|
|
41
|
+
"default": "./dist/server.js"
|
|
42
|
+
},
|
|
43
|
+
"./config": {
|
|
44
|
+
"types": "./dist/config.d.ts",
|
|
45
|
+
"default": "./dist/config.js"
|
|
46
|
+
},
|
|
47
|
+
"./locale/*.json": "./locale/*.json",
|
|
48
|
+
"./icon": "./icon.svg"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"cordis.patch.yml",
|
|
53
|
+
"locale/*.json",
|
|
54
|
+
"icon.svg",
|
|
55
|
+
"README.md",
|
|
56
|
+
"LICENSE"
|
|
57
|
+
],
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsc -p tsconfig.json",
|
|
60
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
61
|
+
"prepublishOnly": "npm run build"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public",
|
|
65
|
+
"registry": "https://registry.npmjs.org/"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@modelcontextprotocol/node": "^2.1.2",
|
|
69
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
70
|
+
"zod": "^4.6.7"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@types/node": "^26.6.6",
|
|
74
|
+
"typescript": "^7.0.2"
|
|
75
|
+
},
|
|
76
|
+
"dsh": {
|
|
77
|
+
"bundle": {
|
|
78
|
+
"patch": "./cordis.patch.yml"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|