offrouter-mcp 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -3
- package/src/index.ts +0 -108
- package/src/server.test.ts +0 -538
- package/src/server.ts +0 -126
- package/src/tools/cancel.ts +0 -73
- package/src/tools/delegate.ts +0 -225
- package/src/tools/explain.ts +0 -57
- package/src/tools/route.ts +0 -49
- package/src/tools/status.ts +0 -74
- package/tsconfig.json +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "offrouter-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -12,8 +12,13 @@
|
|
|
12
12
|
"test": "vitest run --root ../.. packages/mcp/src/server.test.ts"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"offrouter-core": "0.
|
|
15
|
+
"offrouter-core": "0.3.0",
|
|
16
16
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
17
17
|
"zod": "^3.23.0"
|
|
18
|
-
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
]
|
|
19
24
|
}
|
package/src/index.ts
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* offrouter-mcp — stdio MCP server surface for OffRouter.
|
|
4
|
-
*/
|
|
5
|
-
import { realpathSync } from "node:fs";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
-
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
9
|
-
import {
|
|
10
|
-
createOffRouterMcpServer,
|
|
11
|
-
loadOffRouterMcpContext,
|
|
12
|
-
type OffRouterMcpContext,
|
|
13
|
-
} from "./server.js";
|
|
14
|
-
|
|
15
|
-
export {
|
|
16
|
-
createOffRouterMcpContext,
|
|
17
|
-
createOffRouterMcpServer,
|
|
18
|
-
loadOffRouterMcpContext,
|
|
19
|
-
newTaskId,
|
|
20
|
-
type LoadOffRouterMcpContextOptions,
|
|
21
|
-
type OffRouterMcpContext,
|
|
22
|
-
type OffRouterMcpContextOptions,
|
|
23
|
-
} from "./server.js";
|
|
24
|
-
|
|
25
|
-
export const MCP_PACKAGE = "offrouter-mcp" as const;
|
|
26
|
-
|
|
27
|
-
export interface McpIoStream {
|
|
28
|
-
write(chunk: string): boolean | void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface RunMcpCliOptions {
|
|
32
|
-
cwd?: string;
|
|
33
|
-
env?: NodeJS.ProcessEnv;
|
|
34
|
-
stdout?: McpIoStream;
|
|
35
|
-
stderr?: McpIoStream;
|
|
36
|
-
transport?: Transport;
|
|
37
|
-
context?: OffRouterMcpContext;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const HELP = `OffRouter MCP stdio server
|
|
41
|
-
|
|
42
|
-
Usage: offrouter-mcp [--help]
|
|
43
|
-
|
|
44
|
-
Starts the OffRouter MCP server over stdio. Tools exposed:
|
|
45
|
-
offrouter_route
|
|
46
|
-
offrouter_delegate
|
|
47
|
-
offrouter_status
|
|
48
|
-
offrouter_explain_decision
|
|
49
|
-
offrouter_cancel
|
|
50
|
-
|
|
51
|
-
V1 background tasks live only while this MCP server process remains alive.
|
|
52
|
-
`;
|
|
53
|
-
|
|
54
|
-
function defaultStream(stream: NodeJS.WriteStream): McpIoStream {
|
|
55
|
-
return {
|
|
56
|
-
write(chunk: string) {
|
|
57
|
-
return stream.write(chunk);
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function runMcpCli(
|
|
63
|
-
argv: string[] = process.argv.slice(2),
|
|
64
|
-
options: RunMcpCliOptions = {},
|
|
65
|
-
): Promise<number> {
|
|
66
|
-
const stdout = options.stdout ?? defaultStream(process.stdout);
|
|
67
|
-
const stderr = options.stderr ?? defaultStream(process.stderr);
|
|
68
|
-
|
|
69
|
-
if (argv.includes("--help") || argv.includes("-h")) {
|
|
70
|
-
stdout.write(HELP);
|
|
71
|
-
return 0;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (argv.length > 0) {
|
|
75
|
-
stderr.write(`Unknown arguments: ${argv.join(" ")}\n\n${HELP}`);
|
|
76
|
-
return 1;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const context =
|
|
80
|
-
options.context ??
|
|
81
|
-
(await loadOffRouterMcpContext({
|
|
82
|
-
cwd: options.cwd ?? process.cwd(),
|
|
83
|
-
env: options.env ?? process.env,
|
|
84
|
-
}));
|
|
85
|
-
const server = createOffRouterMcpServer(context);
|
|
86
|
-
await server.connect(options.transport ?? new StdioServerTransport());
|
|
87
|
-
return 0;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function isEntrypoint(): boolean {
|
|
91
|
-
if (!process.argv[1]) {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
try {
|
|
95
|
-
return (
|
|
96
|
-
realpathSync(process.argv[1]) ===
|
|
97
|
-
realpathSync(fileURLToPath(import.meta.url))
|
|
98
|
-
);
|
|
99
|
-
} catch {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (isEntrypoint()) {
|
|
105
|
-
void runMcpCli(process.argv.slice(2)).then((code) => {
|
|
106
|
-
process.exitCode = code;
|
|
107
|
-
});
|
|
108
|
-
}
|
package/src/server.test.ts
DELETED
|
@@ -1,538 +0,0 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
-
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
6
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
-
import {
|
|
8
|
-
createFakeProvider,
|
|
9
|
-
type PolicyConfig,
|
|
10
|
-
type ProviderAdapter,
|
|
11
|
-
type ProviderCandidate,
|
|
12
|
-
type RouteDecision,
|
|
13
|
-
type RouteRequest,
|
|
14
|
-
} from "offrouter-core";
|
|
15
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
16
|
-
import { runMcpCli } from "./index.js";
|
|
17
|
-
import {
|
|
18
|
-
createOffRouterMcpContext,
|
|
19
|
-
createOffRouterMcpServer,
|
|
20
|
-
loadOffRouterMcpContext,
|
|
21
|
-
type OffRouterMcpContext,
|
|
22
|
-
} from "./server.js";
|
|
23
|
-
|
|
24
|
-
const policy: PolicyConfig = {
|
|
25
|
-
allowlistedProfiles: ["claude-personal", "codex-personal", "gemini-personal"],
|
|
26
|
-
deniedProfilePatterns: ["*-work"],
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function candidate(
|
|
30
|
-
partial: Partial<ProviderCandidate> &
|
|
31
|
-
Pick<ProviderCandidate, "providerId" | "modelId" | "authTier">,
|
|
32
|
-
): ProviderCandidate {
|
|
33
|
-
return {
|
|
34
|
-
authScope: "third-party",
|
|
35
|
-
health: "healthy",
|
|
36
|
-
supportsTools: true,
|
|
37
|
-
supportsStreaming: true,
|
|
38
|
-
supportsJson: true,
|
|
39
|
-
supportsImages: false,
|
|
40
|
-
estimatedCostUsd: 0,
|
|
41
|
-
...partial,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function route(
|
|
46
|
-
overrides: Partial<RouteRequest> & {
|
|
47
|
-
harness?: Partial<RouteRequest["harness"]>;
|
|
48
|
-
task?: Partial<RouteRequest["task"]>;
|
|
49
|
-
workspace?: Partial<RouteRequest["workspace"]>;
|
|
50
|
-
constraints?: Partial<RouteRequest["constraints"]>;
|
|
51
|
-
} = {},
|
|
52
|
-
): RouteRequest {
|
|
53
|
-
return {
|
|
54
|
-
protocolVersion: "offrouter.route.v1",
|
|
55
|
-
requestId: overrides.requestId ?? "req_mcp_1",
|
|
56
|
-
harness: {
|
|
57
|
-
kind: "claude",
|
|
58
|
-
profile: "claude-personal",
|
|
59
|
-
...overrides.harness,
|
|
60
|
-
},
|
|
61
|
-
task: {
|
|
62
|
-
promptPreview: "explain this repository",
|
|
63
|
-
promptDigest: "sha256:mcp-test",
|
|
64
|
-
kind: "explain",
|
|
65
|
-
risk: "low",
|
|
66
|
-
...overrides.task,
|
|
67
|
-
},
|
|
68
|
-
workspace: {
|
|
69
|
-
cwd: "/tmp/offrouter",
|
|
70
|
-
trusted: true,
|
|
71
|
-
...overrides.workspace,
|
|
72
|
-
},
|
|
73
|
-
constraints: {
|
|
74
|
-
subscriptionFirst: true,
|
|
75
|
-
allowApiKeyFallback: true,
|
|
76
|
-
...overrides.constraints,
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function connectClient(context: OffRouterMcpContext): Promise<{
|
|
82
|
-
client: Client;
|
|
83
|
-
server: McpServer;
|
|
84
|
-
}> {
|
|
85
|
-
const server = createOffRouterMcpServer(context);
|
|
86
|
-
const client = new Client({ name: "offrouter-mcp-test", version: "0.0.0" });
|
|
87
|
-
const [clientTransport, serverTransport] =
|
|
88
|
-
InMemoryTransport.createLinkedPair();
|
|
89
|
-
|
|
90
|
-
await Promise.all([
|
|
91
|
-
server.connect(serverTransport),
|
|
92
|
-
client.connect(clientTransport),
|
|
93
|
-
]);
|
|
94
|
-
|
|
95
|
-
return { client, server };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function structured<T>(result: Awaited<ReturnType<Client["callTool"]>>): T {
|
|
99
|
-
expect(result.isError).not.toBe(true);
|
|
100
|
-
expect(result.structuredContent).toBeTypeOf("object");
|
|
101
|
-
return result.structuredContent as T;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
describe("OffRouter MCP server", () => {
|
|
105
|
-
const clients: Client[] = [];
|
|
106
|
-
const servers: McpServer[] = [];
|
|
107
|
-
|
|
108
|
-
afterEach(async () => {
|
|
109
|
-
await Promise.all(clients.splice(0).map((client) => client.close()));
|
|
110
|
-
await Promise.all(servers.splice(0).map((server) => server.close()));
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("registers offrouter tools and returns a route decision", async () => {
|
|
114
|
-
const context = createOffRouterMcpContext({
|
|
115
|
-
policy,
|
|
116
|
-
candidates: [
|
|
117
|
-
candidate({
|
|
118
|
-
providerId: "fake",
|
|
119
|
-
modelId: "fake-coder",
|
|
120
|
-
authTier: "api-key",
|
|
121
|
-
}),
|
|
122
|
-
],
|
|
123
|
-
adapters: {
|
|
124
|
-
fake: createFakeProvider({ id: "fake" }),
|
|
125
|
-
},
|
|
126
|
-
configuredProfiles: ["claude-personal"],
|
|
127
|
-
configuredProviders: ["fake"],
|
|
128
|
-
});
|
|
129
|
-
const connected = await connectClient(context);
|
|
130
|
-
clients.push(connected.client);
|
|
131
|
-
servers.push(connected.server);
|
|
132
|
-
|
|
133
|
-
const tools = await connected.client.listTools();
|
|
134
|
-
expect(tools.tools.map((tool) => tool.name)).toEqual(
|
|
135
|
-
expect.arrayContaining([
|
|
136
|
-
"offrouter_route",
|
|
137
|
-
"offrouter_delegate",
|
|
138
|
-
"offrouter_status",
|
|
139
|
-
"offrouter_explain_decision",
|
|
140
|
-
"offrouter_cancel",
|
|
141
|
-
]),
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
const result = await connected.client.callTool({
|
|
145
|
-
name: "offrouter_route",
|
|
146
|
-
arguments: { route: route() },
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
const body = structured<{ decision: RouteDecision }>(result);
|
|
150
|
-
expect(body.decision.route).toMatchObject({
|
|
151
|
-
provider: "fake",
|
|
152
|
-
model: "fake-coder",
|
|
153
|
-
authTier: "api-key",
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("delegates to the fake provider and returns generated text", async () => {
|
|
158
|
-
const context = createOffRouterMcpContext({
|
|
159
|
-
policy,
|
|
160
|
-
candidates: [
|
|
161
|
-
candidate({
|
|
162
|
-
providerId: "fake",
|
|
163
|
-
modelId: "fake-coder",
|
|
164
|
-
authTier: "api-key",
|
|
165
|
-
}),
|
|
166
|
-
],
|
|
167
|
-
adapters: {
|
|
168
|
-
fake: createFakeProvider({
|
|
169
|
-
id: "fake",
|
|
170
|
-
responseText: "delegated by fake provider",
|
|
171
|
-
}),
|
|
172
|
-
},
|
|
173
|
-
configuredProfiles: ["claude-personal"],
|
|
174
|
-
configuredProviders: ["fake"],
|
|
175
|
-
});
|
|
176
|
-
const connected = await connectClient(context);
|
|
177
|
-
clients.push(connected.client);
|
|
178
|
-
servers.push(connected.server);
|
|
179
|
-
|
|
180
|
-
const result = await connected.client.callTool({
|
|
181
|
-
name: "offrouter_delegate",
|
|
182
|
-
arguments: {
|
|
183
|
-
prompt: "Full prompt used only after routing allows fake.",
|
|
184
|
-
route: route({ requestId: "req_delegate_mcp" }),
|
|
185
|
-
mode: "oneshot",
|
|
186
|
-
output: "text",
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
const body = structured<{
|
|
191
|
-
result: { status: string; text?: string };
|
|
192
|
-
decision: RouteDecision;
|
|
193
|
-
}>(result);
|
|
194
|
-
expect(body.result.status).toBe("completed");
|
|
195
|
-
expect(body.result.text).toBe("delegated by fake provider");
|
|
196
|
-
expect(body.decision.route?.provider).toBe("fake");
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("aggregates stream mode events without dropping text deltas", async () => {
|
|
200
|
-
const context = createOffRouterMcpContext({
|
|
201
|
-
policy,
|
|
202
|
-
candidates: [
|
|
203
|
-
candidate({
|
|
204
|
-
providerId: "fake",
|
|
205
|
-
modelId: "fake-coder",
|
|
206
|
-
authTier: "api-key",
|
|
207
|
-
}),
|
|
208
|
-
],
|
|
209
|
-
adapters: {
|
|
210
|
-
fake: createFakeProvider({
|
|
211
|
-
id: "fake",
|
|
212
|
-
streamChunks: ["streamed ", "delegate"],
|
|
213
|
-
}),
|
|
214
|
-
},
|
|
215
|
-
configuredProfiles: ["claude-personal"],
|
|
216
|
-
configuredProviders: ["fake"],
|
|
217
|
-
});
|
|
218
|
-
const connected = await connectClient(context);
|
|
219
|
-
clients.push(connected.client);
|
|
220
|
-
servers.push(connected.server);
|
|
221
|
-
|
|
222
|
-
const result = await connected.client.callTool({
|
|
223
|
-
name: "offrouter_delegate",
|
|
224
|
-
arguments: {
|
|
225
|
-
prompt: "Stream this prompt after policy approval.",
|
|
226
|
-
route: route({ requestId: "req_stream_mcp" }),
|
|
227
|
-
mode: "stream",
|
|
228
|
-
output: "text",
|
|
229
|
-
},
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
const body = structured<{
|
|
233
|
-
result: {
|
|
234
|
-
status: string;
|
|
235
|
-
text?: string;
|
|
236
|
-
events: Array<{ type: string; text?: string }>;
|
|
237
|
-
};
|
|
238
|
-
}>(result);
|
|
239
|
-
expect(body.result.status).toBe("completed");
|
|
240
|
-
expect(body.result.text).toBe("streamed delegate");
|
|
241
|
-
expect(
|
|
242
|
-
body.result.events.filter((event) => event.type === "text-delta"),
|
|
243
|
-
).toEqual([
|
|
244
|
-
expect.objectContaining({ text: "streamed " }),
|
|
245
|
-
expect.objectContaining({ text: "delegate" }),
|
|
246
|
-
]);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("reports configured profiles, providers, and active task count", async () => {
|
|
250
|
-
const context = createOffRouterMcpContext({
|
|
251
|
-
policy,
|
|
252
|
-
candidates: [
|
|
253
|
-
candidate({
|
|
254
|
-
providerId: "fake",
|
|
255
|
-
modelId: "fake-coder",
|
|
256
|
-
authTier: "api-key",
|
|
257
|
-
}),
|
|
258
|
-
],
|
|
259
|
-
adapters: {
|
|
260
|
-
fake: createFakeProvider({ id: "fake" }),
|
|
261
|
-
},
|
|
262
|
-
configuredProfiles: ["claude-personal", "codex-personal"],
|
|
263
|
-
configuredProviders: ["fake"],
|
|
264
|
-
});
|
|
265
|
-
const connected = await connectClient(context);
|
|
266
|
-
clients.push(connected.client);
|
|
267
|
-
servers.push(connected.server);
|
|
268
|
-
|
|
269
|
-
const result = await connected.client.callTool({
|
|
270
|
-
name: "offrouter_status",
|
|
271
|
-
arguments: {},
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const body = structured<{
|
|
275
|
-
status: {
|
|
276
|
-
configuredProfiles: string[];
|
|
277
|
-
configuredProviders: string[];
|
|
278
|
-
registeredAdapters: string[];
|
|
279
|
-
invokableProviders: string[];
|
|
280
|
-
configuredButNotInvokableProviders: string[];
|
|
281
|
-
activeTasks: number;
|
|
282
|
-
};
|
|
283
|
-
}>(result);
|
|
284
|
-
expect(body.status.configuredProfiles).toEqual([
|
|
285
|
-
"claude-personal",
|
|
286
|
-
"codex-personal",
|
|
287
|
-
]);
|
|
288
|
-
expect(body.status.configuredProviders).toEqual(["fake"]);
|
|
289
|
-
expect(body.status.registeredAdapters).toEqual(["fake"]);
|
|
290
|
-
expect(body.status.invokableProviders).toEqual(["fake"]);
|
|
291
|
-
expect(body.status.configuredButNotInvokableProviders).toEqual([]);
|
|
292
|
-
expect(body.status.activeTasks).toBe(0);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it("default config context does not claim uninvokable providers", async () => {
|
|
296
|
-
const home = await mkdtemp(join(tmpdir(), "offrouter-mcp-home-"));
|
|
297
|
-
await mkdir(join(home, "profiles"));
|
|
298
|
-
await writeFile(
|
|
299
|
-
join(home, "config.toml"),
|
|
300
|
-
[
|
|
301
|
-
'allowlisted_profiles = ["claude-personal"]',
|
|
302
|
-
"",
|
|
303
|
-
"[providers.openai]",
|
|
304
|
-
"enabled = true",
|
|
305
|
-
"",
|
|
306
|
-
].join("\n"),
|
|
307
|
-
"utf8",
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
const context = await loadOffRouterMcpContext({
|
|
311
|
-
env: {
|
|
312
|
-
OFFROUTER_HOME: home,
|
|
313
|
-
},
|
|
314
|
-
homedir: () => home,
|
|
315
|
-
});
|
|
316
|
-
const connected = await connectClient(context);
|
|
317
|
-
clients.push(connected.client);
|
|
318
|
-
servers.push(connected.server);
|
|
319
|
-
|
|
320
|
-
expect(context.candidates).toEqual([]);
|
|
321
|
-
expect(context.adapters).toEqual({});
|
|
322
|
-
|
|
323
|
-
const status = await connected.client.callTool({
|
|
324
|
-
name: "offrouter_status",
|
|
325
|
-
arguments: {},
|
|
326
|
-
});
|
|
327
|
-
const statusBody = structured<{
|
|
328
|
-
status: {
|
|
329
|
-
configuredProviders: string[];
|
|
330
|
-
registeredAdapters: string[];
|
|
331
|
-
invokableProviders: string[];
|
|
332
|
-
configuredButNotInvokableProviders: string[];
|
|
333
|
-
};
|
|
334
|
-
}>(status);
|
|
335
|
-
expect(statusBody.status.configuredProviders).toEqual(["openai"]);
|
|
336
|
-
expect(statusBody.status.registeredAdapters).toEqual([]);
|
|
337
|
-
expect(statusBody.status.invokableProviders).toEqual([]);
|
|
338
|
-
expect(statusBody.status.configuredButNotInvokableProviders).toEqual([
|
|
339
|
-
"openai",
|
|
340
|
-
]);
|
|
341
|
-
|
|
342
|
-
const result = await connected.client.callTool({
|
|
343
|
-
name: "offrouter_route",
|
|
344
|
-
arguments: { route: route() },
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
const body = structured<{ decision: RouteDecision }>(result);
|
|
348
|
-
expect(body.decision.needsConfiguration).toBe(true);
|
|
349
|
-
expect(body.decision.route).toBeNull();
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it("blocks work profiles through route policy", async () => {
|
|
353
|
-
const context = createOffRouterMcpContext({
|
|
354
|
-
policy,
|
|
355
|
-
candidates: [
|
|
356
|
-
candidate({
|
|
357
|
-
providerId: "fake",
|
|
358
|
-
modelId: "fake-coder",
|
|
359
|
-
authTier: "api-key",
|
|
360
|
-
}),
|
|
361
|
-
],
|
|
362
|
-
adapters: {
|
|
363
|
-
fake: createFakeProvider({ id: "fake" }),
|
|
364
|
-
},
|
|
365
|
-
configuredProfiles: ["claude-personal"],
|
|
366
|
-
configuredProviders: ["fake"],
|
|
367
|
-
});
|
|
368
|
-
const connected = await connectClient(context);
|
|
369
|
-
clients.push(connected.client);
|
|
370
|
-
servers.push(connected.server);
|
|
371
|
-
|
|
372
|
-
const result = await connected.client.callTool({
|
|
373
|
-
name: "offrouter_route",
|
|
374
|
-
arguments: {
|
|
375
|
-
route: route({
|
|
376
|
-
requestId: "req_work_profile",
|
|
377
|
-
harness: { profile: "claude-work" },
|
|
378
|
-
}),
|
|
379
|
-
},
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
const body = structured<{ decision: RouteDecision }>(result);
|
|
383
|
-
expect(body.decision.blocked).toBe(true);
|
|
384
|
-
expect(body.decision.policyDenials?.map((d) => d.code)).toContain(
|
|
385
|
-
"work_profile_denied",
|
|
386
|
-
);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
it("does not invoke an adapter when delegate is blocked by work-profile policy", async () => {
|
|
390
|
-
const base = createFakeProvider({ id: "fake" });
|
|
391
|
-
let invokeCount = 0;
|
|
392
|
-
const adapter: ProviderAdapter = {
|
|
393
|
-
id: base.id,
|
|
394
|
-
listModels: () => base.listModels(),
|
|
395
|
-
checkAuth: () => base.checkAuth(),
|
|
396
|
-
queryLimits: (authTier) => base.queryLimits(authTier),
|
|
397
|
-
estimate: (request, model, authTier) =>
|
|
398
|
-
base.estimate(request, model, authTier),
|
|
399
|
-
async *invoke(request) {
|
|
400
|
-
invokeCount += 1;
|
|
401
|
-
yield* base.invoke(request);
|
|
402
|
-
},
|
|
403
|
-
cancel: (callId) => base.cancel(callId),
|
|
404
|
-
};
|
|
405
|
-
const context = createOffRouterMcpContext({
|
|
406
|
-
policy,
|
|
407
|
-
candidates: [
|
|
408
|
-
candidate({
|
|
409
|
-
providerId: "fake",
|
|
410
|
-
modelId: "fake-coder",
|
|
411
|
-
authTier: "api-key",
|
|
412
|
-
}),
|
|
413
|
-
],
|
|
414
|
-
adapters: { fake: adapter },
|
|
415
|
-
configuredProfiles: ["claude-personal"],
|
|
416
|
-
configuredProviders: ["fake"],
|
|
417
|
-
});
|
|
418
|
-
const connected = await connectClient(context);
|
|
419
|
-
clients.push(connected.client);
|
|
420
|
-
servers.push(connected.server);
|
|
421
|
-
|
|
422
|
-
const result = await connected.client.callTool({
|
|
423
|
-
name: "offrouter_delegate",
|
|
424
|
-
arguments: {
|
|
425
|
-
prompt: "This prompt must not reach the fake adapter.",
|
|
426
|
-
route: route({
|
|
427
|
-
requestId: "req_delegate_work_profile",
|
|
428
|
-
harness: { profile: "claude-work" },
|
|
429
|
-
}),
|
|
430
|
-
mode: "oneshot",
|
|
431
|
-
output: "text",
|
|
432
|
-
},
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
const body = structured<{
|
|
436
|
-
result: { status: string; error?: string };
|
|
437
|
-
decision: RouteDecision;
|
|
438
|
-
}>(result);
|
|
439
|
-
expect(body.result.status).toBe("failed");
|
|
440
|
-
expect(body.decision.blocked).toBe(true);
|
|
441
|
-
expect(invokeCount).toBe(0);
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
it("returns a structured reason when cancelling an unknown task", async () => {
|
|
445
|
-
const context = createOffRouterMcpContext({
|
|
446
|
-
policy,
|
|
447
|
-
candidates: [],
|
|
448
|
-
adapters: {},
|
|
449
|
-
configuredProfiles: ["claude-personal"],
|
|
450
|
-
configuredProviders: [],
|
|
451
|
-
});
|
|
452
|
-
const connected = await connectClient(context);
|
|
453
|
-
clients.push(connected.client);
|
|
454
|
-
servers.push(connected.server);
|
|
455
|
-
|
|
456
|
-
const result = await connected.client.callTool({
|
|
457
|
-
name: "offrouter_cancel",
|
|
458
|
-
arguments: { taskId: "missing_task" },
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
const body = structured<{
|
|
462
|
-
cancelled: boolean;
|
|
463
|
-
error: { code: string; message: string };
|
|
464
|
-
}>(result);
|
|
465
|
-
expect(body.cancelled).toBe(false);
|
|
466
|
-
expect(body.error).toEqual({
|
|
467
|
-
code: "unknown_task",
|
|
468
|
-
message: "No active task found for missing_task.",
|
|
469
|
-
});
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it("cancels an active same-process background delegation task", async () => {
|
|
473
|
-
const context = createOffRouterMcpContext({
|
|
474
|
-
policy,
|
|
475
|
-
candidates: [
|
|
476
|
-
candidate({
|
|
477
|
-
providerId: "fake",
|
|
478
|
-
modelId: "fake-coder",
|
|
479
|
-
authTier: "api-key",
|
|
480
|
-
}),
|
|
481
|
-
],
|
|
482
|
-
adapters: {
|
|
483
|
-
fake: createFakeProvider({ id: "fake", hangUntilCancel: true }),
|
|
484
|
-
},
|
|
485
|
-
configuredProfiles: ["claude-personal"],
|
|
486
|
-
configuredProviders: ["fake"],
|
|
487
|
-
});
|
|
488
|
-
const connected = await connectClient(context);
|
|
489
|
-
clients.push(connected.client);
|
|
490
|
-
servers.push(connected.server);
|
|
491
|
-
|
|
492
|
-
const start = await connected.client.callTool({
|
|
493
|
-
name: "offrouter_delegate",
|
|
494
|
-
arguments: {
|
|
495
|
-
taskId: "task_mcp_cancel",
|
|
496
|
-
prompt: "Start and then cancel this task.",
|
|
497
|
-
route: route({ requestId: "req_cancel_mcp" }),
|
|
498
|
-
mode: "background",
|
|
499
|
-
output: "text",
|
|
500
|
-
},
|
|
501
|
-
});
|
|
502
|
-
const started = structured<{ task: { taskId: string; status: string } }>(
|
|
503
|
-
start,
|
|
504
|
-
);
|
|
505
|
-
expect(started.task.status).toBe("running");
|
|
506
|
-
|
|
507
|
-
const cancel = await connected.client.callTool({
|
|
508
|
-
name: "offrouter_cancel",
|
|
509
|
-
arguments: { taskId: started.task.taskId },
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
const cancelled = structured<{
|
|
513
|
-
task: { taskId: string; status: string };
|
|
514
|
-
cancelled: boolean;
|
|
515
|
-
}>(cancel);
|
|
516
|
-
expect(cancelled.cancelled).toBe(true);
|
|
517
|
-
expect(cancelled.task).toMatchObject({
|
|
518
|
-
taskId: "task_mcp_cancel",
|
|
519
|
-
status: "cancelled",
|
|
520
|
-
});
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
it("prints help for the stdio entrypoint", async () => {
|
|
524
|
-
const chunks: string[] = [];
|
|
525
|
-
|
|
526
|
-
const code = await runMcpCli(["--help"], {
|
|
527
|
-
stdout: {
|
|
528
|
-
write(chunk) {
|
|
529
|
-
chunks.push(chunk);
|
|
530
|
-
},
|
|
531
|
-
},
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
expect(code).toBe(0);
|
|
535
|
-
expect(chunks.join("")).toContain("Usage: offrouter-mcp [--help]");
|
|
536
|
-
expect(chunks.join("")).toContain("MCP stdio server");
|
|
537
|
-
});
|
|
538
|
-
});
|
package/src/server.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OffRouter MCP server — route/delegate/status tools over an in-process runtime.
|
|
3
|
-
*
|
|
4
|
-
* V1 background tasks live only inside the active MCP server process.
|
|
5
|
-
* Harness adapters stay out of this package; core policy + delegation only.
|
|
6
|
-
*/
|
|
7
|
-
import { randomBytes } from "node:crypto";
|
|
8
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
-
import {
|
|
10
|
-
DelegationRuntime,
|
|
11
|
-
loadConfig,
|
|
12
|
-
type DelegationRuntimeOptions,
|
|
13
|
-
type LoadConfigOptions,
|
|
14
|
-
type PolicyConfig,
|
|
15
|
-
type ProviderAdapter,
|
|
16
|
-
type ProviderCandidate,
|
|
17
|
-
} from "offrouter-core";
|
|
18
|
-
import { registerCancelTool } from "./tools/cancel.js";
|
|
19
|
-
import { registerDelegateTool } from "./tools/delegate.js";
|
|
20
|
-
import { registerExplainDecisionTool } from "./tools/explain.js";
|
|
21
|
-
import { registerRouteTool } from "./tools/route.js";
|
|
22
|
-
import { registerStatusTool } from "./tools/status.js";
|
|
23
|
-
|
|
24
|
-
export interface OffRouterMcpContextOptions {
|
|
25
|
-
policy: PolicyConfig;
|
|
26
|
-
candidates: ProviderCandidate[];
|
|
27
|
-
adapters: Record<string, ProviderAdapter>;
|
|
28
|
-
configuredProfiles: string[];
|
|
29
|
-
configuredProviders: string[];
|
|
30
|
-
/** Optional overrides for delegation (tests). */
|
|
31
|
-
delegation?: Partial<
|
|
32
|
-
Pick<DelegationRuntimeOptions, "audit" | "now" | "includePromptPreview">
|
|
33
|
-
>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface OffRouterMcpContext {
|
|
37
|
-
policy: PolicyConfig;
|
|
38
|
-
candidates: ProviderCandidate[];
|
|
39
|
-
adapters: Record<string, ProviderAdapter>;
|
|
40
|
-
configuredProfiles: string[];
|
|
41
|
-
configuredProviders: string[];
|
|
42
|
-
/** In-process delegation runtime for this MCP server instance. */
|
|
43
|
-
runtime: DelegationRuntime;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface LoadOffRouterMcpContextOptions extends Pick<
|
|
47
|
-
LoadConfigOptions,
|
|
48
|
-
"env" | "homedir" | "workspaceTrusted"
|
|
49
|
-
> {
|
|
50
|
-
cwd?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function uniqueSorted(values: string[]): string[] {
|
|
54
|
-
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function createOffRouterMcpContext(
|
|
58
|
-
options: OffRouterMcpContextOptions,
|
|
59
|
-
): OffRouterMcpContext {
|
|
60
|
-
const runtime = new DelegationRuntime({
|
|
61
|
-
adapters: options.adapters,
|
|
62
|
-
candidates: options.candidates,
|
|
63
|
-
policy: options.policy,
|
|
64
|
-
...options.delegation,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
policy: options.policy,
|
|
69
|
-
candidates: options.candidates,
|
|
70
|
-
adapters: options.adapters,
|
|
71
|
-
configuredProfiles: [...options.configuredProfiles],
|
|
72
|
-
configuredProviders: [...options.configuredProviders],
|
|
73
|
-
runtime,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function loadOffRouterMcpContext(
|
|
78
|
-
options: LoadOffRouterMcpContextOptions = {},
|
|
79
|
-
): Promise<OffRouterMcpContext> {
|
|
80
|
-
const cwd = options.cwd ?? process.cwd();
|
|
81
|
-
const config = await loadConfig({
|
|
82
|
-
env: options.env,
|
|
83
|
-
homedir: options.homedir,
|
|
84
|
-
workspaceDir: cwd,
|
|
85
|
-
workspaceTrusted: options.workspaceTrusted ?? false,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
return createOffRouterMcpContext({
|
|
89
|
-
policy: config.policy,
|
|
90
|
-
// Provider discovery supplies candidates/adapters in later phases. The
|
|
91
|
-
// default stdio server should not claim a route it cannot invoke.
|
|
92
|
-
candidates: [],
|
|
93
|
-
adapters: {},
|
|
94
|
-
configuredProfiles: uniqueSorted([
|
|
95
|
-
...config.policy.allowlistedProfiles,
|
|
96
|
-
...Object.keys(config.profiles),
|
|
97
|
-
]),
|
|
98
|
-
configuredProviders: uniqueSorted(
|
|
99
|
-
Object.values(config.providers)
|
|
100
|
-
.filter((provider) => provider.enabled)
|
|
101
|
-
.map((provider) => provider.id),
|
|
102
|
-
),
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function createOffRouterMcpServer(
|
|
107
|
-
context: OffRouterMcpContext,
|
|
108
|
-
): McpServer {
|
|
109
|
-
const server = new McpServer({
|
|
110
|
-
name: "offrouter",
|
|
111
|
-
version: "0.0.0",
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
registerRouteTool(server, context);
|
|
115
|
-
registerDelegateTool(server, context);
|
|
116
|
-
registerStatusTool(server, context);
|
|
117
|
-
registerExplainDecisionTool(server, context);
|
|
118
|
-
registerCancelTool(server, context);
|
|
119
|
-
|
|
120
|
-
return server;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Stable-ish task id generator for callers that omit taskId. */
|
|
124
|
-
export function newTaskId(prefix = "task"): string {
|
|
125
|
-
return `${prefix}_${randomBytes(8).toString("hex")}`;
|
|
126
|
-
}
|
package/src/tools/cancel.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { OffRouterMcpContext } from "../server.js";
|
|
4
|
-
|
|
5
|
-
const inputSchema = {
|
|
6
|
-
taskId: z.string().min(1),
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
const outputSchema = {
|
|
10
|
-
cancelled: z.boolean(),
|
|
11
|
-
task: z.record(z.unknown()).optional(),
|
|
12
|
-
error: z
|
|
13
|
-
.object({
|
|
14
|
-
code: z.string(),
|
|
15
|
-
message: z.string(),
|
|
16
|
-
})
|
|
17
|
-
.optional(),
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Cancel a same-process background delegation task started by this MCP server.
|
|
22
|
-
*/
|
|
23
|
-
export function registerCancelTool(
|
|
24
|
-
server: McpServer,
|
|
25
|
-
context: OffRouterMcpContext,
|
|
26
|
-
): void {
|
|
27
|
-
server.registerTool(
|
|
28
|
-
"offrouter_cancel",
|
|
29
|
-
{
|
|
30
|
-
title: "OffRouter cancel",
|
|
31
|
-
description:
|
|
32
|
-
"Cancel an active background delegation task that lives in this MCP server process only.",
|
|
33
|
-
inputSchema,
|
|
34
|
-
outputSchema,
|
|
35
|
-
},
|
|
36
|
-
async (args) => {
|
|
37
|
-
const task = await context.runtime.cancel(args.taskId);
|
|
38
|
-
if (!task) {
|
|
39
|
-
const structuredContent = {
|
|
40
|
-
cancelled: false,
|
|
41
|
-
task: undefined,
|
|
42
|
-
error: {
|
|
43
|
-
code: "unknown_task",
|
|
44
|
-
message: `No active task found for ${args.taskId}.`,
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
return {
|
|
48
|
-
content: [
|
|
49
|
-
{
|
|
50
|
-
type: "text" as const,
|
|
51
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
structuredContent,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const structuredContent = {
|
|
59
|
-
cancelled: task.status === "cancelled",
|
|
60
|
-
task,
|
|
61
|
-
};
|
|
62
|
-
return {
|
|
63
|
-
content: [
|
|
64
|
-
{
|
|
65
|
-
type: "text" as const,
|
|
66
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
67
|
-
},
|
|
68
|
-
],
|
|
69
|
-
structuredContent,
|
|
70
|
-
};
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
}
|
package/src/tools/delegate.ts
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import {
|
|
3
|
-
DelegationPolicyError,
|
|
4
|
-
RouteRequestSchema,
|
|
5
|
-
type DelegationMode,
|
|
6
|
-
type DelegationEvent,
|
|
7
|
-
type DelegationOutput,
|
|
8
|
-
type DelegationRequest,
|
|
9
|
-
} from "offrouter-core";
|
|
10
|
-
import { z } from "zod";
|
|
11
|
-
import { newTaskId, type OffRouterMcpContext } from "../server.js";
|
|
12
|
-
|
|
13
|
-
const ModeSchema = z.enum(["oneshot", "stream", "background"]);
|
|
14
|
-
const OutputSchema = z.enum(["text", "json", "patch"]);
|
|
15
|
-
|
|
16
|
-
const inputSchema = {
|
|
17
|
-
prompt: z.string().min(1),
|
|
18
|
-
route: RouteRequestSchema,
|
|
19
|
-
mode: ModeSchema.default("oneshot"),
|
|
20
|
-
output: OutputSchema.default("text"),
|
|
21
|
-
taskId: z.string().min(1).optional(),
|
|
22
|
-
model: z.string().min(1).optional(),
|
|
23
|
-
providerId: z.string().min(1).optional(),
|
|
24
|
-
maxOutputTokens: z.number().int().positive().optional(),
|
|
25
|
-
temperature: z.number().optional(),
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const outputSchema = {
|
|
29
|
-
result: z.record(z.unknown()).optional(),
|
|
30
|
-
decision: z.record(z.unknown()).optional(),
|
|
31
|
-
task: z.record(z.unknown()).optional(),
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
function toDelegationRequest(args: {
|
|
35
|
-
prompt: string;
|
|
36
|
-
route: DelegationRequest["route"];
|
|
37
|
-
mode: DelegationMode;
|
|
38
|
-
output: DelegationOutput;
|
|
39
|
-
taskId?: string;
|
|
40
|
-
model?: string;
|
|
41
|
-
providerId?: string;
|
|
42
|
-
maxOutputTokens?: number;
|
|
43
|
-
temperature?: number;
|
|
44
|
-
}): DelegationRequest {
|
|
45
|
-
return {
|
|
46
|
-
taskId: args.taskId ?? newTaskId(),
|
|
47
|
-
mode: args.mode,
|
|
48
|
-
output: args.output,
|
|
49
|
-
prompt: args.prompt,
|
|
50
|
-
route: args.route,
|
|
51
|
-
model: args.model,
|
|
52
|
-
providerId: args.providerId,
|
|
53
|
-
maxOutputTokens: args.maxOutputTokens,
|
|
54
|
-
temperature: args.temperature,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function registerDelegateTool(
|
|
59
|
-
server: McpServer,
|
|
60
|
-
context: OffRouterMcpContext,
|
|
61
|
-
): void {
|
|
62
|
-
server.registerTool(
|
|
63
|
-
"offrouter_delegate",
|
|
64
|
-
{
|
|
65
|
-
title: "OffRouter delegate",
|
|
66
|
-
description:
|
|
67
|
-
"Route then invoke an allowed provider. Full prompt is only sent after policy allows. Background tasks run only in this MCP process.",
|
|
68
|
-
inputSchema,
|
|
69
|
-
outputSchema,
|
|
70
|
-
},
|
|
71
|
-
async (args) => {
|
|
72
|
-
const mode = (args.mode ?? "oneshot") as DelegationMode;
|
|
73
|
-
const output = (args.output ?? "text") as DelegationOutput;
|
|
74
|
-
const request = toDelegationRequest({
|
|
75
|
-
prompt: args.prompt,
|
|
76
|
-
route: args.route,
|
|
77
|
-
mode,
|
|
78
|
-
output,
|
|
79
|
-
taskId: args.taskId,
|
|
80
|
-
model: args.model,
|
|
81
|
-
providerId: args.providerId,
|
|
82
|
-
maxOutputTokens: args.maxOutputTokens,
|
|
83
|
-
temperature: args.temperature,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
if (mode === "background") {
|
|
87
|
-
try {
|
|
88
|
-
const task = await context.runtime.startBackground(request);
|
|
89
|
-
const structuredContent = { task };
|
|
90
|
-
return {
|
|
91
|
-
content: [
|
|
92
|
-
{
|
|
93
|
-
type: "text" as const,
|
|
94
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
structuredContent,
|
|
98
|
-
};
|
|
99
|
-
} catch (err) {
|
|
100
|
-
if (err instanceof DelegationPolicyError) {
|
|
101
|
-
const structuredContent = {
|
|
102
|
-
task: {
|
|
103
|
-
taskId: request.taskId,
|
|
104
|
-
status: "failed",
|
|
105
|
-
},
|
|
106
|
-
decision: err.decision,
|
|
107
|
-
result: {
|
|
108
|
-
status: "failed",
|
|
109
|
-
error: err.message,
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
return {
|
|
113
|
-
content: [
|
|
114
|
-
{
|
|
115
|
-
type: "text" as const,
|
|
116
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
structuredContent,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
throw err;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (mode === "stream") {
|
|
127
|
-
try {
|
|
128
|
-
const events: DelegationEvent[] = [];
|
|
129
|
-
for await (const event of context.runtime.stream(request)) {
|
|
130
|
-
events.push(event);
|
|
131
|
-
}
|
|
132
|
-
const task = context.runtime.getTask(request.taskId);
|
|
133
|
-
const structuredContent = {
|
|
134
|
-
result: {
|
|
135
|
-
status: task?.status ?? "failed",
|
|
136
|
-
text: task?.text,
|
|
137
|
-
error: task?.error,
|
|
138
|
-
taskId: request.taskId,
|
|
139
|
-
events,
|
|
140
|
-
},
|
|
141
|
-
decision: task?.decision,
|
|
142
|
-
};
|
|
143
|
-
return {
|
|
144
|
-
content: [
|
|
145
|
-
{
|
|
146
|
-
type: "text" as const,
|
|
147
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
148
|
-
},
|
|
149
|
-
],
|
|
150
|
-
structuredContent,
|
|
151
|
-
};
|
|
152
|
-
} catch (err) {
|
|
153
|
-
if (err instanceof DelegationPolicyError) {
|
|
154
|
-
const structuredContent = {
|
|
155
|
-
result: {
|
|
156
|
-
status: "failed",
|
|
157
|
-
error: err.message,
|
|
158
|
-
taskId: request.taskId,
|
|
159
|
-
events: context.runtime.getTask(request.taskId)?.events ?? [],
|
|
160
|
-
},
|
|
161
|
-
decision: err.decision,
|
|
162
|
-
};
|
|
163
|
-
return {
|
|
164
|
-
content: [
|
|
165
|
-
{
|
|
166
|
-
type: "text" as const,
|
|
167
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
168
|
-
},
|
|
169
|
-
],
|
|
170
|
-
structuredContent,
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
throw err;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// oneshot
|
|
178
|
-
try {
|
|
179
|
-
const result = await context.runtime.delegate({
|
|
180
|
-
...request,
|
|
181
|
-
mode: "oneshot",
|
|
182
|
-
});
|
|
183
|
-
const structuredContent = {
|
|
184
|
-
result: {
|
|
185
|
-
status: result.status,
|
|
186
|
-
text: result.text,
|
|
187
|
-
error: result.error,
|
|
188
|
-
taskId: result.taskId,
|
|
189
|
-
},
|
|
190
|
-
decision: result.decision,
|
|
191
|
-
};
|
|
192
|
-
return {
|
|
193
|
-
content: [
|
|
194
|
-
{
|
|
195
|
-
type: "text" as const,
|
|
196
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
structuredContent,
|
|
200
|
-
};
|
|
201
|
-
} catch (err) {
|
|
202
|
-
if (err instanceof DelegationPolicyError) {
|
|
203
|
-
const structuredContent = {
|
|
204
|
-
result: {
|
|
205
|
-
status: "failed",
|
|
206
|
-
error: err.message,
|
|
207
|
-
taskId: request.taskId,
|
|
208
|
-
},
|
|
209
|
-
decision: err.decision,
|
|
210
|
-
};
|
|
211
|
-
return {
|
|
212
|
-
content: [
|
|
213
|
-
{
|
|
214
|
-
type: "text" as const,
|
|
215
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
216
|
-
},
|
|
217
|
-
],
|
|
218
|
-
structuredContent,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
throw err;
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
|
-
);
|
|
225
|
-
}
|
package/src/tools/explain.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import {
|
|
3
|
-
RouteRequestSchema,
|
|
4
|
-
routeRequest,
|
|
5
|
-
type RouteDecision,
|
|
6
|
-
} from "offrouter-core";
|
|
7
|
-
import { z } from "zod";
|
|
8
|
-
import type { OffRouterMcpContext } from "../server.js";
|
|
9
|
-
|
|
10
|
-
const inputSchema = {
|
|
11
|
-
route: RouteRequestSchema,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const outputSchema = {
|
|
15
|
-
decision: z.record(z.unknown()),
|
|
16
|
-
explanation: z.string(),
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* offrouter_explain_decision — same scoring path as route, named for harness
|
|
21
|
-
* agents that want an explicit explain call.
|
|
22
|
-
*/
|
|
23
|
-
export function registerExplainDecisionTool(
|
|
24
|
-
server: McpServer,
|
|
25
|
-
context: OffRouterMcpContext,
|
|
26
|
-
): void {
|
|
27
|
-
server.registerTool(
|
|
28
|
-
"offrouter_explain_decision",
|
|
29
|
-
{
|
|
30
|
-
title: "OffRouter explain decision",
|
|
31
|
-
description:
|
|
32
|
-
"Explain why OffRouter would choose (or block) a route for the given request.",
|
|
33
|
-
inputSchema,
|
|
34
|
-
outputSchema,
|
|
35
|
-
},
|
|
36
|
-
async (args) => {
|
|
37
|
-
const decision: RouteDecision = routeRequest(
|
|
38
|
-
args.route,
|
|
39
|
-
context.candidates,
|
|
40
|
-
context.policy,
|
|
41
|
-
);
|
|
42
|
-
const structuredContent = {
|
|
43
|
-
decision,
|
|
44
|
-
explanation: decision.explanation,
|
|
45
|
-
};
|
|
46
|
-
return {
|
|
47
|
-
content: [
|
|
48
|
-
{
|
|
49
|
-
type: "text" as const,
|
|
50
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
51
|
-
},
|
|
52
|
-
],
|
|
53
|
-
structuredContent,
|
|
54
|
-
};
|
|
55
|
-
},
|
|
56
|
-
);
|
|
57
|
-
}
|
package/src/tools/route.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import {
|
|
3
|
-
RouteRequestSchema,
|
|
4
|
-
routeRequest,
|
|
5
|
-
type RouteDecision,
|
|
6
|
-
} from "offrouter-core";
|
|
7
|
-
import { z } from "zod";
|
|
8
|
-
import type { OffRouterMcpContext } from "../server.js";
|
|
9
|
-
|
|
10
|
-
const inputSchema = {
|
|
11
|
-
route: RouteRequestSchema,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const outputSchema = {
|
|
15
|
-
decision: z.record(z.unknown()),
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export function registerRouteTool(
|
|
19
|
-
server: McpServer,
|
|
20
|
-
context: OffRouterMcpContext,
|
|
21
|
-
): void {
|
|
22
|
-
server.registerTool(
|
|
23
|
-
"offrouter_route",
|
|
24
|
-
{
|
|
25
|
-
title: "OffRouter route",
|
|
26
|
-
description:
|
|
27
|
-
"Select a model route for a request using OffRouter policy and ranking. Uses prompt digest/preview only.",
|
|
28
|
-
inputSchema,
|
|
29
|
-
outputSchema,
|
|
30
|
-
},
|
|
31
|
-
async (args) => {
|
|
32
|
-
const decision: RouteDecision = routeRequest(
|
|
33
|
-
args.route,
|
|
34
|
-
context.candidates,
|
|
35
|
-
context.policy,
|
|
36
|
-
);
|
|
37
|
-
const structuredContent = { decision };
|
|
38
|
-
return {
|
|
39
|
-
content: [
|
|
40
|
-
{
|
|
41
|
-
type: "text" as const,
|
|
42
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
structuredContent,
|
|
46
|
-
};
|
|
47
|
-
},
|
|
48
|
-
);
|
|
49
|
-
}
|
package/src/tools/status.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { OffRouterMcpContext } from "../server.js";
|
|
4
|
-
|
|
5
|
-
const inputSchema = {};
|
|
6
|
-
|
|
7
|
-
const outputSchema = {
|
|
8
|
-
status: z.object({
|
|
9
|
-
configuredProfiles: z.array(z.string()),
|
|
10
|
-
configuredProviders: z.array(z.string()),
|
|
11
|
-
registeredAdapters: z.array(z.string()),
|
|
12
|
-
invokableProviders: z.array(z.string()),
|
|
13
|
-
configuredButNotInvokableProviders: z.array(z.string()),
|
|
14
|
-
candidateCount: z.number().int().nonnegative(),
|
|
15
|
-
activeTasks: z.number().int().nonnegative(),
|
|
16
|
-
}),
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function registerStatusTool(
|
|
20
|
-
server: McpServer,
|
|
21
|
-
context: OffRouterMcpContext,
|
|
22
|
-
): void {
|
|
23
|
-
server.registerTool(
|
|
24
|
-
"offrouter_status",
|
|
25
|
-
{
|
|
26
|
-
title: "OffRouter status",
|
|
27
|
-
description:
|
|
28
|
-
"Report configured profiles/providers and active in-process delegation task count for this MCP server.",
|
|
29
|
-
inputSchema,
|
|
30
|
-
outputSchema,
|
|
31
|
-
},
|
|
32
|
-
async () => {
|
|
33
|
-
const activeTasks = context.runtime
|
|
34
|
-
.listTasks()
|
|
35
|
-
.filter((t) => t.status === "running" || t.status === "queued").length;
|
|
36
|
-
const registeredAdapters = Object.keys(context.adapters).sort((a, b) =>
|
|
37
|
-
a.localeCompare(b),
|
|
38
|
-
);
|
|
39
|
-
const candidateProviders = new Set(
|
|
40
|
-
context.candidates.map((candidate) => candidate.providerId),
|
|
41
|
-
);
|
|
42
|
-
const invokableProviders = registeredAdapters.filter((providerId) =>
|
|
43
|
-
candidateProviders.has(providerId),
|
|
44
|
-
);
|
|
45
|
-
const invokable = new Set(invokableProviders);
|
|
46
|
-
const configuredButNotInvokableProviders =
|
|
47
|
-
context.configuredProviders.filter(
|
|
48
|
-
(providerId) => !invokable.has(providerId),
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const structuredContent = {
|
|
52
|
-
status: {
|
|
53
|
-
configuredProfiles: [...context.configuredProfiles],
|
|
54
|
-
configuredProviders: [...context.configuredProviders],
|
|
55
|
-
registeredAdapters,
|
|
56
|
-
invokableProviders,
|
|
57
|
-
configuredButNotInvokableProviders,
|
|
58
|
-
candidateCount: context.candidates.length,
|
|
59
|
-
activeTasks,
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
content: [
|
|
65
|
-
{
|
|
66
|
-
type: "text" as const,
|
|
67
|
-
text: JSON.stringify(structuredContent, null, 2),
|
|
68
|
-
},
|
|
69
|
-
],
|
|
70
|
-
structuredContent,
|
|
71
|
-
};
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
}
|