Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/glean/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"args": ["${CLAUDE_PLUGIN_ROOT}/start.sh"],
"env": {
"ENABLE_HITL": "true",
"HITL_TIMEOUT_MS": "300000"
"HITL_TIMEOUT_MS": "300000",
"DANGEROUSLY_ENABLE_UNSTABLE_RUN_CODE_FEATURE": "true"
}
}
}
Expand Down
2,138 changes: 1,394 additions & 744 deletions plugins/glean/dist/index.js

Large diffs are not rendered by default.

154 changes: 153 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "./auth-callback-server.js";
import { handleFindSkills } from "./tools/find-skills.js";
import { handleRunTool, runToolAnnotations } from "./tools/run-tool.js";
import { handleRunCode } from "./tools/run-code.js";
import { evictStaleSkills } from "./skill-writer.js";
import {
loadServerUrl,
Expand Down Expand Up @@ -121,6 +122,13 @@ function resolveSkillsBaseDir(): string {
return path.join("/tmp", "glean-skills-cache");
}

// Experimental "code mode". When ON, the model additionally gets `run_code`: it
// writes JavaScript that calls each downstream tool as an async `PTC_<TOOL>()`
// function, and the plugin executes that code in a persistent sandbox. Default
// OFF so deployed behavior (run_tool) is untouched.
const RUN_CODE_ENABLED =
process.env.DANGEROUSLY_ENABLE_UNSTABLE_RUN_CODE_FEATURE === "true";

const server = new Server(
{ name: "glean", version: "1.0.0" },
{ capabilities: { tools: { listChanged: true } } },
Expand Down Expand Up @@ -236,6 +244,74 @@ const RUN_TOOL_TOOL: Tool = {
},
};

const RUN_CODE_TOOL: Tool = {
name: "run_code",
description:
"Run JavaScript that calls Glean tools as async functions — best for BATCH " +
"jobs (2+ calls: chaining, fan-out, or a loop). For a single one-off call, " +
"`run_tool` is simpler. Each discovered tool is `PTC_<TOOL_NAME>(args)` " +
"(server_id bound for you; first-class tools like `PTC_search` too). Call " +
"find_skills first and read each tool's JSON for its arg names.\n\n" +
"A PTC_ call resolves to a ToolResult: `.json()` (parsed, or undefined for " +
"non-JSON — branch on `.format` = 'json'|'text'|'empty', NOT `if(r.json())`), " +
"`.text`, `.get('a.b', fallback)` (never throws). `return` sends a value back " +
"VERBATIM. So return/print ONLY what you need; full data stays " +
"in the runtime. To persist a variable across calls use a BARE assignment " +
"(`x = await PTC_...()`); var/let/const are temporary. `inspect(x)` shows a " +
"value's shape; `print(...)` for output. Top-level await works. " +
"Call run_code ONE AT A TIME — await each call's result before issuing the " +
"next; do NOT make parallel run_code calls.\n\n" +
"The result is JSON: { ok, value?, stdout?, session?, error? }. A failed PTC_ call " +
"THROWS (`Error: PTC_<TOOL> failed: <reason>`); an uncaught throw ends the cell " +
"with `ok:false` + `error.message`. Use try/catch to handle a failure " +
"or keep a batch going (writes already made are NOT rolled back). " +
"See find_skills output for the full guide.",
inputSchema: {
type: "object" as const,
properties: {
code: {
type: "string",
description:
"JavaScript to execute. Tools are async `PTC_<TOOL_NAME>(args)` " +
"functions. Use `return` to send a value back.",
},
reset: {
type: "boolean",
description:
"If true, clear all persisted session variables and start a fresh " +
"runtime before executing this code.",
},
},
required: ["code"],
},
outputSchema: {
type: "object" as const,
properties: {
ok: {
type: "boolean",
description: "true if the cell finished without throwing.",
},
// The cell's return value, verbatim — any JSON type; omitted on error.
value: {},
stdout: { type: "string", description: "print(...) output, if any." },
session: {
type: "object",
properties: { fresh: { type: "boolean" } },
},
error: {
type: "object",
properties: {
message: {
type: "string",
description: "Thrown message, e.g. `PTC_X failed: <reason>`.",
},
},
},
},
required: ["ok"],
},
};

const SETUP_TOOL: Tool = {
name: "setup",
annotations: { readOnlyHint: true },
Expand Down Expand Up @@ -280,7 +356,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
!!server.getClientCapabilities()?.elicitation,
),
};
const staticTools: Tool[] = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL];
// Code mode ADDS run_code alongside run_tool (it does not replace it):
// run_tool handles single one-off calls, run_code is for batches (2+ calls /
// chaining / fan-out). When the flag is off the surface is identical to
// what's deployed today.
const staticTools: Tool[] = RUN_CODE_ENABLED
? [FIND_SKILLS_TOOL, runTool, RUN_CODE_TOOL, SETUP_TOOL]
: [FIND_SKILLS_TOOL, runTool, SETUP_TOOL];

// One structured line on every return path, so "why don't my tools appear?"
// is answerable from the log alone: `static` is constant, `names` lists the
Expand Down Expand Up @@ -552,6 +634,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
// setup has provided a server URL. Auth is handled by dispatchRemoteTool
// via the standard [AUTHENTICATION_REQUIRED] flow.
if (REMOTE_TOOLS_ALLOWLIST.has(name)) {
// First-class tools run directly. A single call belongs here, not in
// run_code (which is scoped to batches); they remain usable as PTC_<name>
// inside run_code when the model is batching/composing.
const serverUrl = resolveServerUrl();
if (!serverUrl) {
return {
Expand Down Expand Up @@ -630,6 +715,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
remoteClient,
skillsBaseDir,
args,
{ codeMode: RUN_CODE_ENABLED },
);
return { content: [{ type: "text", text }] };
} catch (err) {
Expand Down Expand Up @@ -704,6 +790,72 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}
}

case "run_code": {
if (!RUN_CODE_ENABLED) {
return {
content: [{ type: "text", text: "run_code is not enabled." }],
isError: true,
};
}

const serverUrl = resolveServerUrl();
if (!serverUrl) {
return {
content: [{ type: "text", text: SETUP_NEEDED_ERROR }],
isError: true,
};
}
if (!getOAuthProvider().tokens()) {
return {
content: [{ type: "text", text: AUTH_REDIRECT_TO_SETUP_TEXT }],
};
}

const sessionId = resolveSessionId();

let remoteClient;
try {
remoteClient = await createRemoteClient(
serverUrl,
getRemoteClientOpts(),
sessionId,
);
} catch (err) {
if (err instanceof AuthRequiredError) {
return {
content: [{ type: "text", text: AUTH_REDIRECT_TO_SETUP_TEXT }],
};
}
const msg = err instanceof Error ? err.message : String(err);
logLine("connect.backend-error", { label: "run_code", msg });
return {
content: [
{ type: "text", text: `Failed to connect to Glean backend: ${msg}` },
],
isError: true,
};
}
try {
const skillsBaseDir = resolveSkillsBaseDir();
return await handleRunCode(
remoteClient,
server,
skillsBaseDir,
args,
cachedRemoteTools,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`run_code: execution failed: ${msg}`);
return {
content: [{ type: "text", text: `run_code failed: ${msg}` }],
isError: true,
};
} finally {
await remoteClient.close();
}
}

case "setup": {
logLine("client.capabilities", {
elicitation: server.getClientCapabilities()?.elicitation ?? null,
Expand Down
152 changes: 152 additions & 0 deletions src/skill-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import fs from "node:fs/promises";
import path from "node:path";

/**
* Metadata for a single downstream tool, read from a cached
* <skillsBaseDir>/<skill>/tools/<TOOL>.json file. The find_skills flow writes
* these files; both run_tool (for HITL lookup) and run_code (for binding
* generation) read them through here.
*/
export interface ToolMeta {
/** Tool name == the JSON filename stem (e.g. "JIRA_CREATE_ISSUE"). */
toolName: string;
/** Owning skill directory name. */
skillName: string;
/** Downstream MCP server id this tool dispatches to ("" for direct tools). */
serverId: string;
requiresApproval: boolean;
/**
* "Head"/first-class remote tools (search, read_document, …) are called
* directly on the remote client by name, NOT via the run_tool gateway. The
* run_code bridge checks this flag to pick the dispatch path.
*/
direct: boolean;
description: string;
}

interface RawToolJson {
server_id?: string;
requires_approval?: boolean;
direct?: boolean;
description?: string;
}

/** Minimal shape of a head/first-class remote tool (from tools/list). */
export interface HeadTool {
name: string;
description?: string;
inputSchema?: unknown;
}

// Head tools are written under this synthetic skill dir so discoverTools binds
// them uniformly. The leading "_" keeps it out of the way alphabetically and
// out of the find_skills response skill set (writeSkillsToDisk never rm's it).
export const CORE_SKILL = "_core";

/**
* Discover every cached tool across all skills, in deterministic order
* (skills sorted, then tools sorted). Deterministic ordering matters because
* run_tool's old findToolJson relied on undefined readdir order, which made
* cross-skill tool-name collisions resolve to an arbitrary server_id. Callers
* that need a single tool by name should use findToolMeta, which surfaces
* collisions instead of silently picking the first match.
*/
export async function discoverTools(skillsBaseDir: string): Promise<ToolMeta[]> {
let skillDirs;
try {
skillDirs = await fs.readdir(skillsBaseDir, { withFileTypes: true });
} catch {
return [];
}

const out: ToolMeta[] = [];
const sortedSkills = skillDirs
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();

for (const skillName of sortedSkills) {
const toolsDir = path.join(skillsBaseDir, skillName, "tools");
let toolFiles;
try {
toolFiles = await fs.readdir(toolsDir);
} catch {
continue;
}
for (const file of toolFiles.filter((f) => f.endsWith(".json")).sort()) {
const toolName = file.slice(0, -".json".length);
try {
const raw = JSON.parse(
await fs.readFile(path.join(toolsDir, file), "utf-8"),
) as RawToolJson;
const direct = raw.direct === true;
// Gateway tools need a server_id; direct (head) tools don't.
if (typeof raw.server_id !== "string" && !direct) continue;
out.push({
toolName,
skillName,
serverId: typeof raw.server_id === "string" ? raw.server_id : "",
requiresApproval: raw.requires_approval === true,
direct,
description: typeof raw.description === "string" ? raw.description : "",
});
} catch {
continue;
}
}
}
return out;
}

/**
* Find a single tool by name (first match in deterministic order), or null.
*/
export async function findToolMeta(
skillsBaseDir: string,
toolName: string,
): Promise<ToolMeta | null> {
const all = await discoverTools(skillsBaseDir);
return all.find((t) => t.toolName === toolName) ?? null;
}

/**
* Materialize the head/first-class remote tools as `_core/tools/<name>.json`
* files (tagged direct:true) so discoverTools binds them and the model can read
* their inputSchema like any other tool. rm-and-recreates so a tool dropped
* from the allow-list disappears. No-op (and leaves any prior _core intact)
* when the head-tool list is empty, so a transient empty cache doesn't wipe it.
*/
export async function writeCoreTools(
skillsBaseDir: string,
headTools: HeadTool[],
): Promise<void> {
if (!headTools.length) return;
const coreDir = path.join(skillsBaseDir, CORE_SKILL);
const toolsDir = path.join(coreDir, "tools");
try {
await fs.rm(coreDir, { recursive: true, force: true });
await fs.mkdir(toolsDir, { recursive: true });
await Promise.all(
headTools.map((t) =>
fs.writeFile(
path.join(toolsDir, `${t.name}.json`),
JSON.stringify(
{
name: t.name,
direct: true,
requires_approval: false,
description: t.description ?? "",
inputSchema: t.inputSchema ?? { type: "object", properties: {} },
},
null,
2,
),
"utf-8",
),
),
);
} catch {
/* best-effort */
}
}

Loading
Loading