authorgravatar for 43469098+nkoynov@users.noreply.github.comNikita Koynov <43469098+nkoynov@users.noreply.github.com> 2026-10-06 12:38:02+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 04:38:02-05:00
log9bd1d8009a6b7c50f9dd9458e2bf27d481ff3b43
tree30b70377bfc908dbd2369c63b7a6665ec29cb6ce
parente6abc01788eef557c214a086999b3fd635b69f71
signature Signed by PGP key B5690EEEBB952194

fix(server): OpenCode 2 threads get T3 Code's MCP tools (#16142)


2 files changed, 92 insertions(+), 3 deletions(-)

apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+81-1
......@@ -46,7 +46,7 @@ import * as IdAllocator from "../IdAllocator.ts";
4646import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
4747import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";
4848import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts";
49import { OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts";
49import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts";
5050import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts";
5151
5252const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX";
......@@ -2702,6 +2702,86 @@ describe("OpenCode2 adapter", () => {
27022702 }).pipe(Effect.scoped),
27032703 );
27042704
2705 it.effect("registers a long thread id's MCP server under a name OpenCode accepts", () =>
2706 Effect.gen(function* () {
2707 // Spelled out, this delegated thread's server name would be 120 characters.
2708 const child = ThreadId.make(
2709 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Asubproject-b-round1",
2710 );
2711 const server = "t3-code-aa73fa1e03099934";
2712 McpProviderSession.setMcpProviderSession({
2713 environmentId: EnvironmentId.make("environment:opencode2-adapter"),
2714 threadId: child,
2715 providerSessionId: "mcp:opencode2-adapter",
2716 providerInstanceId: instanceId,
2717 endpoint: "http://127.0.0.1:3773/mcp",
2718 authorizationHeader: "Bearer thread-credential",
2719 browserToolsAvailable: false,
2720 });
2721 yield* Effect.addFinalizer(() =>
2722 Effect.sync(() => McpProviderSession.clearMcpProviderSession(child)),
2723 );
2724 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
2725 ...opening,
2726 out("session.get", { sessionID: SESSION }),
2727 // The session already has this thread's rules, so they are not rewritten.
2728 replyData(
2729 "session.get",
2730 sessionInfo({
2731 permissions: [
2732 { action: "*", resource: "*", effect: "allow" },
2733 { action: "t3-code-*", resource: "*", effect: "deny" },
2734 { action: `${server}_*`, resource: "*", effect: "allow" },
2735 ],
2736 }),
2737 ),
2738 ...noOpenRequests,
2739 out("mcp.add", {
2740 server,
2741 "location[directory]": WORK,
2742 config: {
2743 type: "remote",
2744 url: "http://127.0.0.1:3773/mcp",
2745 headers: { Authorization: "Bearer thread-credential" },
2746 oauth: false,
2747 },
2748 }),
2749 reply("mcp.add", null),
2750 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
2751 promptAccepted,
2752 event("session.execution.succeeded", { sessionID: SESSION }),
2753 out("mcp.remove", { server, "location[directory]": WORK }),
2754 reply("mcp.remove", null),
2755 ]);
2756 const thread = yield* runtime.resumeThread({
2757 providerThread: { ...providerThread(yield* DateTime.now), appThreadId: child },
2758 threadId: child,
2759 modelSelection: bigPickle,
2760 runtimePolicy: policy(),
2761 });
2762 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
2763 yield* runtime.startTurn({ ...turnInput(thread), threadId: child });
2764 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
2765 yield* runtime.unloadThread!({ providerThread: thread });
2766 }).pipe(Effect.scoped),
2767 );
2768
2769 it("names each thread's MCP server within OpenCode's limits, one name per thread", () => {
2770 const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258";
2771 const ids = [
2772 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`,
2773 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`,
2774 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1",
2775 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2",
2776 ];
2777 const names = ids.map(t3McpServerName);
2778 for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/);
2779 assert.equal(new Set(names).size, ids.length);
2780 assert.deepEqual(ids.map(t3McpServerName), names);
2781 // A name that already fits stays readable.
2782 assert.equal(t3McpServerName(threadId), "t3-code-thread_opencode2-adapter");
2783 });
2784
27052785 it.effect("reads user and assistant text from the session's message list", () =>
27062786 Effect.gen(function* () {
27072787 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts+11-2
......@@ -21,6 +21,8 @@
2121 *
2222 * @module orchestration-v2/Adapters/OpenCode2AdapterV2
2323 */
24import * as NodeCrypto from "node:crypto";
25
2426import {
2527 AbsolutePath,
2628 Agent,
......@@ -447,9 +449,16 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour
447449 * T3's MCP server is registered per directory, not per session, so each thread
448450 * gets its own `t3-code-<thread>` entry with its own credential. OpenCode names
449451 * an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`).
452 * OpenCode skips every tool of a server whose name is over 64 characters (its
453 * tool namespace limit), and its router rejects adding one over 100, so a
454 * thread id that does not fit is replaced by a digest of it.
450455 */
451const t3McpServerName = (threadId: string) =>
452 `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
456export const t3McpServerName = (threadId: string) => {
457 const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
458 if (name.length <= 64) return name;
459 const digest = NodeCrypto.createHash("sha256").update(threadId).digest("hex");
460 return `t3-code-${digest.slice(0, 16)}`;
461};
453462
454463/**
455464 * The rules that keep T3's MCP servers to their own thread, after the mode's: