@openshain/mcp 0.1.1 → 0.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/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +286 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +20 -0
- package/package.json +17 -6
- package/src/server.ts +4 -2
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { type RuntimeProviders } from "@openshain/core";
|
|
3
|
+
export interface McpServerOptions {
|
|
4
|
+
workspaceRoot: string;
|
|
5
|
+
/** Tool providers by the id used in openshain.yaml. */
|
|
6
|
+
tools: RuntimeProviders["tools"];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* An MCP server over one workspace. The agent on the other side thinks; the server keeps the
|
|
10
|
+
* work's state, runs the workspace's tools and records everything. Needs no model provider.
|
|
11
|
+
* Calls are handled one at a time per connection.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createMcpServer(options: McpServerOptions): Promise<Server>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { compileInputValidator, createToolCaller, createToolRegistry, isOpenshainError, isTerminal, loadConfig, parseWorkId, resolveWorkspacePath, SESSION_WORK_TYPE, uuidv7, verifyArtifact, WorkStore, } from "@openshain/core";
|
|
4
|
+
import pkg from "../package.json" with { type: "json" };
|
|
5
|
+
import { Session } from "./session.js";
|
|
6
|
+
/** The tools every session has, before the workspace's own. Their names are reserved in the runtime. */
|
|
7
|
+
const WORK_TOOLS = [
|
|
8
|
+
{
|
|
9
|
+
name: "work_create",
|
|
10
|
+
description: "Start a work for a request from the person you work for, and make it the current work. Tool calls are recorded against the current work. Finish the current work with work_complete or work_fail before starting another.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
objective: { type: "string", description: "The request, in the person's words." },
|
|
15
|
+
type: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "A short label for the kind of work. Defaults to request.",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
required: ["objective"],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "work_select",
|
|
26
|
+
description: "Make an existing work the current one. A finished work cannot be selected.",
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: { id: { type: "string" } },
|
|
30
|
+
required: ["id"],
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "work_get",
|
|
36
|
+
description: "The state of the current work, or of the work with the given id.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { id: { type: "string" } },
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "work_list",
|
|
45
|
+
description: "Every work in this workspace, oldest first, with the ones that cannot be read.",
|
|
46
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "work_complete",
|
|
50
|
+
description: "Finish the current work. Say what was done; name the files you produced with their sha256 if you know it. Paths are relative to the workspace. The runtime checks every file and records its own hash.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
summary: { type: "string" },
|
|
55
|
+
artifacts: {
|
|
56
|
+
type: "array",
|
|
57
|
+
items: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: { path: { type: "string" }, sha256: { type: "string" } },
|
|
60
|
+
required: ["path"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ["summary"],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "work_fail",
|
|
71
|
+
description: "Give up on the current work. Say why in a short reason and, if useful, a detail.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: { reason: { type: "string" }, detail: { type: "string" } },
|
|
75
|
+
required: ["reason"],
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
const NO_WORK = "no current work: call work_create to start one for the person's request, or work_select to pick an existing one";
|
|
81
|
+
const validators = new Map(WORK_TOOLS.map((tool) => [tool.name, compileInputValidator(tool.inputSchema)]));
|
|
82
|
+
/**
|
|
83
|
+
* An MCP server over one workspace. The agent on the other side thinks; the server keeps the
|
|
84
|
+
* work's state, runs the workspace's tools and records everything. Needs no model provider.
|
|
85
|
+
* Calls are handled one at a time per connection.
|
|
86
|
+
*/
|
|
87
|
+
export async function createMcpServer(options) {
|
|
88
|
+
const { workspaceRoot } = options;
|
|
89
|
+
const config = await loadConfig(workspaceRoot);
|
|
90
|
+
const registry = await createToolRegistry(workspaceRoot, config, options.tools);
|
|
91
|
+
const callTool = createToolCaller({ registry, config, workspaceRoot });
|
|
92
|
+
const works = new WorkStore(workspaceRoot);
|
|
93
|
+
const session = new Session();
|
|
94
|
+
const server = new Server({ name: "openshain", version: pkg.version }, { capabilities: { tools: {} } });
|
|
95
|
+
/** The current work when it can still take events; otherwise the reason it cannot. */
|
|
96
|
+
async function openWork() {
|
|
97
|
+
const id = session.current;
|
|
98
|
+
if (!id)
|
|
99
|
+
return { refused: failure(NO_WORK) };
|
|
100
|
+
const work = await works.get(id);
|
|
101
|
+
if (isTerminal(work.status)) {
|
|
102
|
+
session.clear();
|
|
103
|
+
return { refused: failure(`work ${id} is already ${work.status}; ${NO_WORK}`) };
|
|
104
|
+
}
|
|
105
|
+
return { id };
|
|
106
|
+
}
|
|
107
|
+
async function handle(name, input) {
|
|
108
|
+
const validate = validators.get(name);
|
|
109
|
+
if (validate) {
|
|
110
|
+
const checked = validate(input);
|
|
111
|
+
if (!checked.ok) {
|
|
112
|
+
return failure(`schema_mismatch: input does not match the schema of ${name}: ${checked.reason}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
switch (name) {
|
|
116
|
+
case "work_create": {
|
|
117
|
+
const current = session.current;
|
|
118
|
+
if (current && !isTerminal((await works.get(current)).status)) {
|
|
119
|
+
return failure(`work ${current} is still in progress; finish it with work_complete or work_fail before starting another`);
|
|
120
|
+
}
|
|
121
|
+
const { objective, type } = input;
|
|
122
|
+
if (type === SESSION_WORK_TYPE) {
|
|
123
|
+
return failure(`type "${SESSION_WORK_TYPE}" is reserved for conversations; use another label, such as request`);
|
|
124
|
+
}
|
|
125
|
+
const work = await works.create({
|
|
126
|
+
objective,
|
|
127
|
+
principal: config.principal.id,
|
|
128
|
+
profession: config.profession.id,
|
|
129
|
+
...(type && { type }),
|
|
130
|
+
});
|
|
131
|
+
await works.transition(work.id, "in_progress", "an agent took the work over MCP");
|
|
132
|
+
session.select(work.id);
|
|
133
|
+
return json(await works.get(work.id));
|
|
134
|
+
}
|
|
135
|
+
case "work_select": {
|
|
136
|
+
const id = parseWorkId(input.id);
|
|
137
|
+
const work = await works.get(id);
|
|
138
|
+
if (isTerminal(work.status))
|
|
139
|
+
return failure(`work ${id} is already ${work.status}`);
|
|
140
|
+
session.select(id);
|
|
141
|
+
return json(work);
|
|
142
|
+
}
|
|
143
|
+
case "work_get": {
|
|
144
|
+
const given = input.id;
|
|
145
|
+
const id = given ? parseWorkId(given) : session.current;
|
|
146
|
+
if (!id)
|
|
147
|
+
return failure(NO_WORK);
|
|
148
|
+
return json(await works.get(id));
|
|
149
|
+
}
|
|
150
|
+
case "work_list": {
|
|
151
|
+
const { works: all, problems } = await works.list();
|
|
152
|
+
return json({
|
|
153
|
+
works: all.map((w) => ({
|
|
154
|
+
id: w.id,
|
|
155
|
+
status: w.status,
|
|
156
|
+
objective: w.objective,
|
|
157
|
+
createdAt: w.createdAt,
|
|
158
|
+
})),
|
|
159
|
+
problems: problems.map((p) => ({
|
|
160
|
+
id: p.id,
|
|
161
|
+
code: p.error.code,
|
|
162
|
+
message: p.error.message,
|
|
163
|
+
})),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
case "work_complete": {
|
|
167
|
+
const gate = await openWork();
|
|
168
|
+
if ("refused" in gate)
|
|
169
|
+
return gate.refused;
|
|
170
|
+
const { summary, artifacts } = input;
|
|
171
|
+
const work = await complete(works, workspaceRoot, gate.id, summary, artifacts ?? []);
|
|
172
|
+
session.clear();
|
|
173
|
+
return json(work);
|
|
174
|
+
}
|
|
175
|
+
case "work_fail": {
|
|
176
|
+
const gate = await openWork();
|
|
177
|
+
if ("refused" in gate)
|
|
178
|
+
return gate.refused;
|
|
179
|
+
const { reason, detail } = input;
|
|
180
|
+
const opened = await works.open(gate.id);
|
|
181
|
+
try {
|
|
182
|
+
await opened.append({ type: "work.failed", payload: { reason, detail: detail ?? "" } });
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await opened.close();
|
|
186
|
+
}
|
|
187
|
+
session.clear();
|
|
188
|
+
return json(await works.get(gate.id));
|
|
189
|
+
}
|
|
190
|
+
default: {
|
|
191
|
+
const gate = await openWork();
|
|
192
|
+
if ("refused" in gate)
|
|
193
|
+
return gate.refused;
|
|
194
|
+
const opened = await works.open(gate.id);
|
|
195
|
+
try {
|
|
196
|
+
const result = await callTool(opened, { id: newCallId(), name, input });
|
|
197
|
+
return toMcpResult(result);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await opened.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
206
|
+
tools: [...WORK_TOOLS, ...registry.list().map((t) => toMcpTool(t.definition))],
|
|
207
|
+
}));
|
|
208
|
+
server.setRequestHandler(CallToolRequestSchema, (request) => session.run(async () => {
|
|
209
|
+
try {
|
|
210
|
+
return await handle(request.params.name, request.params.arguments ?? {});
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
if (isOpenshainError(err))
|
|
214
|
+
return failure(`${err.code}: ${err.message}`);
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
}));
|
|
218
|
+
return server;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Records the evidence and the completion. Artifacts named by the agent join the ones the tools
|
|
222
|
+
* wrote; every path must be inside the workspace, and the runtime hashes them all. A path no tool
|
|
223
|
+
* of this work wrote is marked claimed, so a reader can tell the agent's word from the record.
|
|
224
|
+
*/
|
|
225
|
+
async function complete(works, workspaceRoot, id, summary, claimed) {
|
|
226
|
+
for (const { path } of claimed)
|
|
227
|
+
await resolveWorkspacePath(workspaceRoot, path);
|
|
228
|
+
const opened = await works.open(id);
|
|
229
|
+
try {
|
|
230
|
+
const events = await opened.events();
|
|
231
|
+
const refs = [];
|
|
232
|
+
const byPath = new Map();
|
|
233
|
+
for (const event of writesWithAfter(events)) {
|
|
234
|
+
refs.push(event.id);
|
|
235
|
+
for (const { path, sha256 } of event.payload.after ?? [])
|
|
236
|
+
byPath.set(path, sha256);
|
|
237
|
+
}
|
|
238
|
+
const written = new Set(byPath.keys());
|
|
239
|
+
for (const { path, sha256 } of claimed)
|
|
240
|
+
if (!byPath.has(path))
|
|
241
|
+
byPath.set(path, sha256 ?? "");
|
|
242
|
+
const artifacts = [];
|
|
243
|
+
for (const [path, reported] of byPath) {
|
|
244
|
+
const artifact = await verifyArtifact(workspaceRoot, path, reported);
|
|
245
|
+
artifacts.push(written.has(path) ? artifact : { ...artifact, claimed: true });
|
|
246
|
+
}
|
|
247
|
+
await opened.append({
|
|
248
|
+
type: "evidence.recorded",
|
|
249
|
+
payload: { claim: summary, refs, artifacts },
|
|
250
|
+
});
|
|
251
|
+
await opened.append({ type: "work.completed", payload: { summary } });
|
|
252
|
+
return opened.current();
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await opened.close();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function writesWithAfter(events) {
|
|
259
|
+
return events.filter((e) => e.type === "tool.completed" &&
|
|
260
|
+
!e.payload.isError &&
|
|
261
|
+
e.payload.after !== undefined);
|
|
262
|
+
}
|
|
263
|
+
function toMcpTool(definition) {
|
|
264
|
+
return {
|
|
265
|
+
name: definition.name,
|
|
266
|
+
description: definition.description,
|
|
267
|
+
inputSchema: definition.inputSchema,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function toMcpResult(result) {
|
|
271
|
+
return {
|
|
272
|
+
content: result.content.map((part) => part.type === "text"
|
|
273
|
+
? { type: "text", text: part.text }
|
|
274
|
+
: { type: "text", text: JSON.stringify(part.value) }),
|
|
275
|
+
...(result.isError && { isError: true }),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function json(value) {
|
|
279
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
280
|
+
}
|
|
281
|
+
function failure(text) {
|
|
282
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
283
|
+
}
|
|
284
|
+
function newCallId() {
|
|
285
|
+
return `call_${uuidv7()}`;
|
|
286
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { WorkId } from "@openshain/core";
|
|
2
|
+
/** What one connection remembers: the work the agent is on, and the order of its calls. */
|
|
3
|
+
export declare class Session {
|
|
4
|
+
private currentId;
|
|
5
|
+
private queue;
|
|
6
|
+
get current(): WorkId | undefined;
|
|
7
|
+
select(id: WorkId): void;
|
|
8
|
+
clear(): void;
|
|
9
|
+
/** Runs one call after the previous one finished, so calls the agent makes in parallel do not fight over the work's lock. */
|
|
10
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
11
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** What one connection remembers: the work the agent is on, and the order of its calls. */
|
|
2
|
+
export class Session {
|
|
3
|
+
currentId;
|
|
4
|
+
queue = Promise.resolve();
|
|
5
|
+
get current() {
|
|
6
|
+
return this.currentId;
|
|
7
|
+
}
|
|
8
|
+
select(id) {
|
|
9
|
+
this.currentId = id;
|
|
10
|
+
}
|
|
11
|
+
clear() {
|
|
12
|
+
this.currentId = undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Runs one call after the previous one finished, so calls the agent makes in parallel do not fight over the work's lock. */
|
|
15
|
+
run(fn) {
|
|
16
|
+
const next = this.queue.then(fn, fn);
|
|
17
|
+
this.queue = next.catch(() => undefined);
|
|
18
|
+
return next;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openshain/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server that exposes an openshain workspace to any agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openshain",
|
|
@@ -21,25 +21,36 @@
|
|
|
21
21
|
"bugs": "https://github.com/openshain/openshain/issues",
|
|
22
22
|
"type": "module",
|
|
23
23
|
"engines": {
|
|
24
|
+
"node": ">=22",
|
|
24
25
|
"bun": ">=1.3"
|
|
25
26
|
},
|
|
26
27
|
"files": [
|
|
28
|
+
"dist",
|
|
27
29
|
"src",
|
|
28
30
|
"!src/**/*.test.ts",
|
|
31
|
+
"!src/**/*.test.tsx",
|
|
29
32
|
"README.md",
|
|
30
33
|
"LICENSE"
|
|
31
34
|
],
|
|
32
35
|
"exports": {
|
|
33
|
-
".":
|
|
36
|
+
".": {
|
|
37
|
+
"bun": "./src/index.ts",
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
}
|
|
34
41
|
},
|
|
35
|
-
"
|
|
36
|
-
"
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "../../node_modules/.bin/tsc -p tsconfig.build.json",
|
|
44
|
+
"prepublishOnly": "rm -rf dist && ../../node_modules/.bin/tsc -p tsconfig.build.json"
|
|
37
45
|
},
|
|
38
46
|
"dependencies": {
|
|
39
47
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
40
|
-
"@openshain/core": "0.
|
|
48
|
+
"@openshain/core": "0.2.0"
|
|
41
49
|
},
|
|
42
50
|
"devDependencies": {
|
|
43
|
-
"@openshain/tools": "0.
|
|
51
|
+
"@openshain/tools": "0.2.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
44
55
|
}
|
|
45
56
|
}
|
package/src/server.ts
CHANGED
|
@@ -22,11 +22,13 @@ import {
|
|
|
22
22
|
SESSION_WORK_TYPE,
|
|
23
23
|
type ToolDefinition,
|
|
24
24
|
type ToolResult,
|
|
25
|
+
uuidv7,
|
|
25
26
|
verifyArtifact,
|
|
26
27
|
type Work,
|
|
27
28
|
type WorkId,
|
|
28
29
|
WorkStore,
|
|
29
30
|
} from "@openshain/core";
|
|
31
|
+
import pkg from "../package.json" with { type: "json" };
|
|
30
32
|
import { Session } from "./session.ts";
|
|
31
33
|
|
|
32
34
|
export interface McpServerOptions {
|
|
@@ -132,7 +134,7 @@ export async function createMcpServer(options: McpServerOptions): Promise<Server
|
|
|
132
134
|
const works = new WorkStore(workspaceRoot);
|
|
133
135
|
const session = new Session();
|
|
134
136
|
const server = new Server(
|
|
135
|
-
{ name: "openshain", version:
|
|
137
|
+
{ name: "openshain", version: pkg.version },
|
|
136
138
|
{ capabilities: { tools: {} } },
|
|
137
139
|
);
|
|
138
140
|
|
|
@@ -344,5 +346,5 @@ function failure(text: string): CallToolResult {
|
|
|
344
346
|
}
|
|
345
347
|
|
|
346
348
|
function newCallId(): string {
|
|
347
|
-
return `call_${
|
|
349
|
+
return `call_${uuidv7()}`;
|
|
348
350
|
}
|