feat(ops): add workspace MCP deployment overlays

This commit is contained in:
Codex
2026-07-19 12:34:06 +03:00
parent 56b766b23b
commit 847a08da93
28 changed files with 9865 additions and 0 deletions
@@ -0,0 +1,156 @@
import Fastify, { type FastifyInstance } from "fastify";
import { ZodError } from "zod";
import type { AppConfig } from "./config.js";
import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
import { AgentsRepository } from "./repositories/agents.js";
import { registerAgentRoutes } from "./routes/agents.js";
import { registerEngineGatewayRoutes } from "./routes/engine.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerInstallerRoutes } from "./routes/install.js";
import { registerMcpRoutes } from "./routes/mcp.js";
import { registerOntologyGatewayRoutes } from "./routes/ontology.js";
import { registerPublicRoutes } from "./routes/public.js";
import { registerToolRoutes } from "./routes/tools.js";
import { ForbiddenError } from "./security/authorization.js";
import { UnauthorizedError } from "./security/bearer.js";
import { InternalAuthNotConfiguredError } from "./security/internal.js";
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
const pool = createPool(config);
const agentsRepository = pool ? new AgentsRepository(pool) : null;
const taskerClient = new TaskerClient({
baseUrl: config.NODEDC_TASKER_INTERNAL_URL,
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
});
const app = Fastify({
bodyLimit: 10 * 1024 * 1024,
logger: {
level: config.LOG_LEVEL,
},
});
app.addHook("onClose", async () => {
await pool?.end();
});
app.setErrorHandler((error, _request, reply) => {
if (error instanceof ZodError) {
void reply.status(400).send({
ok: false,
error: "validation_error",
details: error.issues,
});
return;
}
if (error instanceof DatabaseNotConfiguredError) {
void reply.status(503).send({
ok: false,
error: "database_not_configured",
message: "DATABASE_URL is required for Agent Gateway persistence endpoints.",
});
return;
}
if (error instanceof UnauthorizedError) {
void reply.status(401).send({
ok: false,
error: "unauthorized",
message: error.message,
});
return;
}
if (error instanceof InternalAuthNotConfiguredError) {
void reply.status(503).send({
ok: false,
error: "internal_auth_not_configured",
message: error.message,
});
return;
}
if (error instanceof ForbiddenError) {
void reply.status(403).send({
ok: false,
error: "forbidden",
message: error.message,
});
return;
}
if (error instanceof ToolExecutionInputError) {
void reply.status(error.httpStatus).send({
ok: false,
error: error.code,
message: error.message,
details: error.details,
});
return;
}
if (error instanceof TaskerAdapterNotConfiguredError) {
void reply.status(503).send({
ok: false,
error: "tasker_adapter_not_configured",
message: error.message,
});
return;
}
if (error instanceof TaskerAdapterError) {
void reply.status(error.statusCode).send({
ok: false,
error: "tasker_adapter_error",
message: error.message,
tasker_status: error.statusCode,
tasker_payload: error.payload,
});
return;
}
if (error instanceof TaskerAdapterUnavailableError) {
void reply.status(502).send({
ok: false,
error: "tasker_adapter_unavailable",
message: error.message,
});
return;
}
app.log.error(error);
void reply.status(500).send({
ok: false,
error: "internal_server_error",
message: "Agent Gateway request failed.",
});
});
await registerPublicRoutes(app);
await registerInstallerRoutes(app, { publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL });
await registerHealthRoutes(app, config, pool);
await registerAgentRoutes(app, {
agentsRepository,
codexInstallChannel: config.NODEDC_OPS_CODEX_INSTALL_CHANNEL,
codexNpmSpec: config.NODEDC_OPS_CODEX_NPM_SPEC,
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN,
aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS,
});
await registerEngineGatewayRoutes(app, {
engineInternalUrl: config.NODEDC_ENGINE_INTERNAL_URL,
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
});
await registerToolRoutes(app, { agentsRepository, taskerClient });
await registerMcpRoutes(app, { agentsRepository, taskerClient });
await registerOntologyGatewayRoutes(app, {
agentsRepository,
ontologyCoreUrl: config.NODEDC_ONTOLOGY_CORE_URL,
ontologyCoreAccessToken: config.NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN ?? config.NODEDC_INTERNAL_ACCESS_TOKEN,
});
return app;
}
@@ -0,0 +1,36 @@
# NODE.DC Ops Codex
Installs NODE.DC Ops MCP access into a local Codex Desktop/CLI configuration.
## Setup
Generate a one-time setup code in NODE.DC Ops, then run:
```sh
npx --yes @nodedc/ops-codex setup ndcsetup_...
```
The setup command redeems the one-time code, writes independent `nodedc-ops-agent` and read-only `nodedc_ontology` MCP server blocks into `~/.codex/config.toml`, installs the `ops-context` skill, and checks both MCP tool lists.
Restart Codex Desktop after setup. A new chat is not enough if the existing Desktop process already loaded MCP config.
The installer auto-detects existing Codex homes. It checks `$CODEX_HOME`, `~/.codex`, common OS app-data Codex folders, and portable-style `.codex` folders near the current directory. If multiple existing Codex homes are found, setup writes all of them. If none are found, setup creates `~/.codex`.
## Commands
```sh
ops-codex setup <setup-code>
ops-codex status
ops-codex doctor
```
`doctor` checks the local config, installed skill, and MCP connectivity.
## Options
```sh
--gateway <url> Ops Agent Gateway URL. Defaults to https://ops-agents.nodedc.ru.
--codex-home <path> Codex home directory. Overrides auto-discovery.
```
For an unusual portable Codex layout, run setup from the portable app folder or pass `--codex-home <path>` explicitly.
@@ -0,0 +1,792 @@
#!/usr/bin/env node
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const DEFAULT_GATEWAY = "https://ops-agents.nodedc.ru";
const SERVER_NAME = "nodedc-ops-agent";
const ONTOLOGY_SERVER_NAME = "nodedc_ontology";
const SKILL_NAME = "ops-context";
const MCP_PROTOCOL_VERSION = "2025-06-18";
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp(args.command);
return;
}
if (args.command === "setup") {
await runSetup(args);
return;
}
if (args.command === "status") {
const report = await inspectLocalInstalls(args.codexHome, { smoke: false });
printInstallReport(report);
process.exitCode = report.ok ? 0 : 1;
return;
}
if (args.command === "doctor") {
const report = await inspectLocalInstalls(args.codexHome, { smoke: true });
printInstallReport(report);
process.exitCode = report.ok ? 0 : 1;
return;
}
throw new UsageError(`Unknown command: ${args.command}`);
}
async function runSetup(args) {
if (!args.setupCode) {
throw new UsageError("Missing setup code. Use: ops-codex setup <ndcsetup_...>");
}
const gateway = String(args.gateway || process.env.NODEDC_OPS_AGENT_GATEWAY || DEFAULT_GATEWAY).replace(/\/+$/, "");
const redeemPayload = await redeemSetupCode(gateway, args.setupCode);
const setup = redeemPayload.setup || {};
const mcpServer = setup.mcp_server || {};
const ops = redeemPayload.ops || {
serverName: SERVER_NAME,
mcpUrl: String(mcpServer.url || `${gateway}/mcp`),
token: redeemPayload.token,
};
const ontology = redeemPayload.ontology;
if (!ops.token || !ontology?.token) {
throw new Error("Setup code redeem did not return independent Ops and Ontology credentials.");
}
const opsEndpoint = String(ops.mcpUrl || `${gateway}/mcp`);
const ontologyEndpoint = String(ontology.mcpUrl || `${gateway}/ontology-mcp`);
const agentsMd = setup.agents_md || defaultSkillBody(opsEndpoint);
const targets = await resolveCodexTargets(args.codexHome);
for (const target of targets) {
const configPath = path.join(target.codexHome, "config.toml");
const skillPath = path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md");
await writeCodexConfig(configPath, [
{ serverName: String(ops.serverName || SERVER_NAME), endpoint: opsEndpoint, token: ops.token },
{
serverName: String(ontology.serverName || ONTOLOGY_SERVER_NAME),
endpoint: ontologyEndpoint,
token: ontology.token,
},
]);
await writeSkill(skillPath, agentsMd, opsEndpoint, ontologyEndpoint);
}
const opsToolCount = await smokeToolsList(opsEndpoint, ops.token);
const ontologyToolCount = await smokeToolsList(ontologyEndpoint, ontology.token);
console.log("NODE.DC Ops Codex setup complete.");
for (const target of targets) {
console.log("Config:", path.join(target.codexHome, "config.toml"));
console.log("Skill:", path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md"));
console.log("Target:", target.reason);
}
console.log("Ops MCP smoke tools:", opsToolCount);
console.log("Ontology MCP smoke tools:", ontologyToolCount);
console.log("Run: ops-codex doctor");
console.log("Restart Codex Desktop completely before testing. A new chat is not enough if the Desktop process already loaded MCP config.");
console.log("In the next session, tool discovery must expose tasker_list_projects. If it does not, Codex has not loaded the NODE.DC Ops MCP server.");
}
function parseArgs(rawArgs) {
let args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
let command = "setup";
if (args[0] === "setup" || args[0] === "status" || args[0] === "doctor") {
command = args[0];
args = args.slice(1);
} else if (args[0] === "help") {
return { codexHome: "", command: "help", gateway: "", help: true, setupCode: "" };
}
const parsed = {
codexHome: "",
command,
gateway: "",
help: false,
setupCode: "",
};
const positional = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
parsed.help = true;
continue;
}
if (arg === "--gateway") {
parsed.gateway = requireValue(args, ++i, "--gateway");
continue;
}
if (arg.startsWith("--gateway=")) {
parsed.gateway = arg.slice("--gateway=".length);
continue;
}
if (arg === "--setup-code" || arg === "--code") {
parsed.setupCode = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--setup-code=")) {
parsed.setupCode = arg.slice("--setup-code=".length);
continue;
}
if (arg.startsWith("--code=")) {
parsed.setupCode = arg.slice("--code=".length);
continue;
}
if (arg === "--codex-home") {
parsed.codexHome = requireValue(args, ++i, "--codex-home");
continue;
}
if (arg.startsWith("--codex-home=")) {
parsed.codexHome = arg.slice("--codex-home=".length);
continue;
}
if (arg.startsWith("-")) {
throw new UsageError(`Unknown argument: ${arg}`);
}
positional.push(arg);
}
if (parsed.command === "setup") {
if (!parsed.setupCode && positional[0]) {
parsed.setupCode = positional[0];
}
if (positional.length > 1) {
throw new UsageError(`Unexpected extra argument: ${positional[1]}`);
}
} else if (positional.length) {
throw new UsageError(`${parsed.command} does not accept positional arguments.`);
}
return parsed;
}
function requireValue(args, index, flag) {
const value = args[index];
if (!value || value.startsWith("--")) {
throw new UsageError(`Missing value for ${flag}.`);
}
return value;
}
function printHelp(command = "") {
if (command === "status") {
console.log(`Show local NODE.DC Ops Codex install status.
Usage:
ops-codex status [--codex-home <path>]
`);
return;
}
if (command === "doctor") {
console.log(`Check local NODE.DC Ops Codex install and MCP connectivity.
Usage:
ops-codex doctor [--codex-home <path>]
`);
return;
}
console.log(`Install NODE.DC Ops MCP access into local Codex.
Usage:
ops-codex setup <setup-code> [--gateway <url>] [--codex-home <path>]
ops-codex setup --code <setup-code> [--gateway <url>] [--codex-home <path>]
ops-codex status [--codex-home <path>]
ops-codex doctor [--codex-home <path>]
Registry form after publish:
npx --yes @nodedc/ops-codex setup <setup-code>
Self-host fallback:
npm exec --yes --package=<gateway>/install/nodedc-ops-codex-setup.tgz -- ops-codex setup <setup-code>
Options:
--code, --setup-code <code> One-time setup code generated by NODE.DC Ops.
--gateway <url> Ops Agent Gateway URL. Defaults to ${DEFAULT_GATEWAY}.
--codex-home <path> Codex home directory. Overrides auto-discovery.
-h, --help Show this help.
Codex home discovery:
By default, the installer writes every existing Codex-like home it can detect:
$CODEX_HOME, ~/.codex, common OS app-data Codex folders, and portable-style
.codex folders near the current directory. If none exist, it creates ~/.codex.
Legacy compatibility:
nodedc-ops-codex --setup-code <setup-code>
`);
}
async function redeemSetupCode(gateway, setupCode) {
let response;
try {
response = await fetch(`${gateway}/api/v1/setup-codes/redeem`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ setup_code: setupCode }),
});
} catch (error) {
throw new Error(`Setup code redeem failed: ${error.message}`);
}
const bodyText = await response.text();
let data;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch {
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
}
if (!response.ok) {
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
}
if (!data.ok || !data.token || !data.ontology?.token) {
throw new Error(`Setup code redeem failed: ${JSON.stringify(data)}`);
}
return data;
}
async function resolveCodexTargets(codexHomeArg) {
if (codexHomeArg) {
return [{ codexHome: expandHome(codexHomeArg), reason: "--codex-home" }];
}
if (process.env.CODEX_HOME) {
return [{ codexHome: expandHome(process.env.CODEX_HOME), reason: "CODEX_HOME" }];
}
const candidates = buildCodexHomeCandidates();
const existing = [];
for (const candidate of candidates) {
if (await looksLikeCodexHome(candidate.codexHome)) {
existing.push(candidate);
}
}
const dedupedExisting = dedupeTargets(existing);
if (dedupedExisting.length > 0) {
return dedupedExisting;
}
return [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }];
}
function buildCodexHomeCandidates() {
const home = os.homedir();
const candidates = [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }];
if (process.platform === "darwin") {
candidates.push(
{
codexHome: path.join(home, "Library", "Application Support", "Codex"),
reason: "macOS Application Support Codex",
},
{
codexHome: path.join(home, "Library", "Application Support", "OpenAI", "Codex"),
reason: "macOS Application Support OpenAI Codex",
}
);
}
if (process.platform === "win32") {
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
candidates.push(
{ codexHome: path.join(appData, "Codex"), reason: "Windows APPDATA Codex" },
{ codexHome: path.join(appData, "OpenAI", "Codex"), reason: "Windows APPDATA OpenAI Codex" },
{ codexHome: path.join(localAppData, "Codex"), reason: "Windows LOCALAPPDATA Codex" },
{ codexHome: path.join(localAppData, "OpenAI", "Codex"), reason: "Windows LOCALAPPDATA OpenAI Codex" }
);
}
for (const portableHome of portableCodexHomeCandidates(process.cwd())) {
candidates.push(portableHome);
}
return dedupeTargets(candidates);
}
function portableCodexHomeCandidates(startDir) {
const candidates = [];
let current = path.resolve(startDir);
const stopAt = path.parse(current).root;
for (let depth = 0; depth < 4; depth += 1) {
candidates.push(
{ codexHome: path.join(current, ".codex"), reason: "portable .codex near current directory" },
{ codexHome: path.join(current, "data", ".codex"), reason: "portable data/.codex near current directory" },
{ codexHome: path.join(current, "profile", ".codex"), reason: "portable profile/.codex near current directory" },
{ codexHome: path.join(current, "portable", ".codex"), reason: "portable portable/.codex near current directory" }
);
if (current === stopAt) {
break;
}
current = path.dirname(current);
}
return candidates;
}
function defaultCodexHome() {
return path.join(os.homedir(), ".codex");
}
async function looksLikeCodexHome(codexHome) {
if (await pathExists(path.join(codexHome, "config.toml"))) {
return true;
}
if (await pathExists(path.join(codexHome, "skills"))) {
return true;
}
const baseName = path.basename(codexHome).toLowerCase();
return baseName === ".codex" && (await pathExists(codexHome));
}
async function pathExists(targetPath) {
try {
await stat(targetPath);
return true;
} catch (error) {
if (error && error.code === "ENOENT") {
return false;
}
throw error;
}
}
function dedupeTargets(targets) {
const seen = new Set();
const result = [];
for (const target of targets) {
const key = normalizePathKey(target.codexHome);
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({ codexHome: path.resolve(target.codexHome), reason: target.reason });
}
return result;
}
function normalizePathKey(value) {
const resolved = path.resolve(value);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function expandHome(value) {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(os.homedir(), value.slice(2));
}
return path.resolve(value);
}
async function writeCodexConfig(configPath, servers) {
await mkdir(path.dirname(configPath), { recursive: true });
const original = await readTextIfExists(configPath);
let backupPath = "";
if (original !== null) {
backupPath = `${configPath}.nodedc-bak`;
await writeFile(backupPath, original, "utf8");
}
let nextText = stripServerSections(
original || "",
servers.map((server) => server.serverName)
).trimEnd();
if (nextText) {
nextText += "\n\n";
}
nextText += servers
.map((server) => buildMcpConfigBlock(server.serverName, server.endpoint, server.token))
.join("\n\n");
nextText += "\n";
await writeFile(configPath, nextText, "utf8");
if (backupPath) {
console.log("Backup:", backupPath);
}
}
async function readTextIfExists(filePath) {
try {
return await readFile(filePath, "utf8");
} catch (error) {
if (error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
function stripServerSections(text, serverNames) {
const targets = new Set(serverNames.map((serverName) => `mcp_servers.${serverName}`));
const kept = [];
let skip = false;
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
if (match) {
const section = match[1].trim();
skip = [...targets].some((target) => section === target || section.startsWith(`${target}.`));
}
if (!skip) {
kept.push(line);
}
}
return kept.join("\n");
}
function buildMcpConfigBlock(serverName, endpoint, token) {
return [
`[mcp_servers.${serverName}]`,
`url = ${tomlString(endpoint)}`,
"enabled = true",
"required = false",
"startup_timeout_sec = 20",
"tool_timeout_sec = 60",
"",
`[mcp_servers.${serverName}.http_headers]`,
`Authorization = ${tomlString(`Bearer ${token}`)}`,
`Accept = ${tomlString("application/json")}`,
`${tomlString("MCP-Protocol-Version")} = ${tomlString(MCP_PROTOCOL_VERSION)}`,
].join("\n");
}
function tomlString(value) {
return JSON.stringify(String(value));
}
async function writeSkill(skillPath, agentsMd, endpoint, ontologyEndpoint) {
await mkdir(path.dirname(skillPath), { recursive: true });
await writeFile(skillPath, buildSkillBody(agentsMd, endpoint, ontologyEndpoint), "utf8");
}
function buildSkillBody(agentsMd, endpoint, ontologyEndpoint) {
return [
"---",
"name: ops-context",
"description: Use when working with NODE.DC Ops/Tasker cards, project cards, checkers, labels, comments, or when the user asks for ops-context. Always use the direct nodedc-ops-agent tasker_* MCP tools and never the old codex_apps OPS widgets.",
"---",
"",
"# ops-context",
"",
"Use this skill when the user asks to work with NODE.DC Ops, Tasker cards, project cards, checkers, labels, comments, or asks for ops-context.",
"",
"## Hard Rules",
"",
"- Treat Ops MCP as the source of truth for cards and project context.",
"- Treat `nodedc_ontology` as a separate read-only semantic authority. Use it before inventing entity ids, semantic types, aliases, relations, or platform routing assumptions.",
"- Ontology tools and credentials are never embedded or multiplexed into Ops MCP.",
"- Use only the direct nodedc-ops-agent MCP tools for Ops cards. The expected tool names are tasker_get_agent_instructions, tasker_list_projects, tasker_get_project_context, tasker_search_issues, and the other tasker_* tools listed below.",
"- Do not use Codex Apps workspace widgets for Ops: never call tools from codex_apps servers named nodedc_ops_readonly, nodedc_ops_gateway_readonly, ops-readonly, or ops_gateway_readonly. They are known-broken for Tasker cards and waste context.",
"- Do not open current workspace widgets, CodexPro cards, .ai-bridge handoff files, local thread lists, or broad filesystem searches to answer Ops card requests.",
"- MCP tools can be lazy-loaded. If mcp__nodedc_ops_agent.tasker_list_projects is not already visible in the active tools, do not answer yet.",
"- First call the available tool discovery/search tool, normally tool_search.tool_search_tool, with query exactly: tasker_list_projects nodedc-ops-agent NODE.DC Ops MCP",
"- After discovery returns NODE.DC Ops tools, immediately call mcp__nodedc_ops_agent.tasker_get_agent_instructions, then mcp__nodedc_ops_agent.tasker_list_projects, then mcp__nodedc_ops_agent.tasker_get_project_context for the target project.",
"- Resolve workspace/project from tasker_list_projects and granted project context. Do not guess a workspace slug from user wording.",
"- Stop only if no discovery/search tool is available, or discovery returns no mcp__nodedc_ops_agent tasker_* tools. Then tell the user: 'NODE.DC Ops MCP is installed in config.toml, but this Codex session did not expose the nodedc-ops-agent tools after tool discovery.' Do not fall back to filesystem search, config.toml parsing, curl, or Codex Apps widgets unless the user explicitly asks to debug installation.",
"- Never print Authorization headers, setup codes, or agent tokens.",
"- Every write must use the official Tasker MCP write tool and a fresh idempotency_key.",
"- Read a card with `tasker_get_issue` before claiming its comments or attachments are unavailable.",
"- Create projects only through `tasker_create_project` when the effective workspace grant includes `project:create`.",
"- Attach local files only through `tasker_attach_file`; never pass arbitrary server paths or remote URLs.",
"- Do not delete or archive cards, comments, labels, projects, states, members, or workspaces.",
"",
"## Installed Endpoint",
"",
endpoint,
"",
"## Separate read-only Ontology endpoint",
"",
ontologyEndpoint,
"",
"## Gateway Rules Snapshot",
"",
agentsMd.trim(),
"",
].join("\n");
}
function defaultSkillBody(endpoint) {
return [
"# NODE.DC Ops Agent Rules",
"",
`MCP endpoint: ${endpoint}`,
"",
"Call tasker_get_agent_instructions, tasker_list_projects, and tasker_get_project_context before writing Tasker cards.",
].join("\n");
}
async function inspectLocalInstall(codexHome, options) {
const configPath = path.join(codexHome, "config.toml");
const skillPath = path.join(codexHome, "skills", SKILL_NAME, "SKILL.md");
const checks = [];
const configText = await readTextIfExists(configPath);
const skillText = await readTextIfExists(skillPath);
const opsConfig = configText ? parseCodexMcpConfig(configText, SERVER_NAME) : null;
const ontologyConfig = configText ? parseCodexMcpConfig(configText, ONTOLOGY_SERVER_NAME) : null;
const endpoint = opsConfig?.server.url || "";
const authorization = opsConfig?.headers.Authorization || "";
const ontologyEndpoint = ontologyConfig?.server.url || "";
const ontologyAuthorization = ontologyConfig?.headers.Authorization || "";
checks.push({ ok: Boolean(configText), name: "config", detail: configPath });
checks.push({
ok: Boolean(opsConfig?.found),
name: "Ops MCP server",
detail: opsConfig?.found ? SERVER_NAME : "missing [mcp_servers.nodedc-ops-agent]",
});
checks.push({
ok: Boolean(endpoint),
name: "endpoint",
detail: endpoint || "missing url",
});
checks.push({
ok: Boolean(parsedConfig?.headers && authorization),
name: "authorization",
detail: authorization ? "present" : "missing http_headers.Authorization",
});
checks.push({
ok: Boolean(ontologyConfig?.found),
name: "Ontology MCP server",
detail: ontologyConfig?.found ? ONTOLOGY_SERVER_NAME : "missing [mcp_servers.nodedc_ontology]",
});
checks.push({
ok: Boolean(ontologyEndpoint),
name: "Ontology endpoint",
detail: ontologyEndpoint || "missing url",
});
checks.push({
ok: Boolean(ontologyAuthorization),
name: "Ontology authorization",
detail: ontologyAuthorization ? "present" : "missing http_headers.Authorization",
});
checks.push({
ok: Boolean(skillText),
name: "skill",
detail: skillPath,
});
checks.push({
ok: Boolean(skillText?.startsWith("---\nname: ops-context")),
name: "skill frontmatter",
detail: skillText ? "present" : "not checked",
});
checks.push({
ok: Boolean(skillText?.includes("tool_search.tool_search_tool")),
name: "lazy discovery rule",
detail: skillText ? "present" : "not checked",
});
let smoke = null;
if (options.smoke) {
if (endpoint && authorization && ontologyEndpoint && ontologyAuthorization) {
try {
const opsToolCount = await smokeToolsListWithAuthorization(endpoint, authorization);
const ontologyToolCount = await smokeToolsListWithAuthorization(ontologyEndpoint, ontologyAuthorization);
smoke = { ok: true, detail: `${opsToolCount} Ops tools; ${ontologyToolCount} Ontology tools` };
} catch (error) {
smoke = { ok: false, detail: error.message || String(error) };
}
} else {
smoke = { ok: false, detail: "Ops/Ontology endpoint or Authorization header is missing" };
}
checks.push({ ok: smoke.ok, name: "mcp smoke", detail: smoke.detail });
}
return {
codexHome,
configPath,
endpoint,
ok: checks.every((check) => check.ok),
skillPath,
checks,
};
}
async function inspectLocalInstalls(codexHomeArg, options) {
const targets = await resolveCodexTargets(codexHomeArg);
const reports = [];
for (const target of targets) {
reports.push({
target,
report: await inspectLocalInstall(target.codexHome, options),
});
}
return {
ok: reports.some((entry) => entry.report.ok),
reports,
};
}
function parseCodexMcpConfig(text, serverName) {
const target = `mcp_servers.${serverName}`;
const headers = `${target}.http_headers`;
let current = "";
const result = { found: false, headers: {}, server: {} };
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
current = sectionMatch[1].trim();
if (current === target || current === headers) {
result.found = true;
}
continue;
}
if (current !== target && current !== headers) {
continue;
}
const keyValue = line.match(/^("[^"]+"|[A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
if (!keyValue) {
continue;
}
const key = parseTomlKey(keyValue[1]);
const value = parseTomlValue(keyValue[2]);
if (current === target) {
result.server[key] = value;
} else {
result.headers[key] = value;
}
}
return result;
}
function parseTomlKey(value) {
const trimmed = value.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
return JSON.parse(trimmed);
}
return trimmed;
}
function parseTomlValue(value) {
const trimmed = stripTomlInlineComment(value.trim());
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed.slice(1, -1);
}
}
if (trimmed === "true") return true;
if (trimmed === "false") return false;
return trimmed;
}
function stripTomlInlineComment(value) {
let inString = false;
let escaped = false;
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (char === "#" && !inString) {
return value.slice(0, i).trimEnd();
}
}
return value;
}
function printInstallReport(result) {
console.log("NODE.DC Ops Codex status");
for (const entry of result.reports) {
const report = entry.report;
console.log("Codex home:", report.codexHome);
console.log("Target:", entry.target.reason);
for (const check of report.checks) {
console.log(`${check.ok ? "OK" : "FAIL"} ${check.name}: ${check.detail}`);
}
}
if (result.ok) {
console.log("Result: ready");
} else {
console.log("Result: needs attention");
}
}
async function smokeToolsList(endpoint, token) {
return smokeToolsListWithAuthorization(endpoint, `Bearer ${token}`);
}
async function smokeToolsListWithAuthorization(endpoint, authorization) {
let response;
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: authorization,
Accept: "application/json",
"Content-Type": "application/json",
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
} catch (error) {
throw new Error(`MCP smoke check failed: ${error.message}`);
}
const bodyText = await response.text();
let data;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch {
throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`);
}
if (!response.ok) {
throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`);
}
if (data.error) {
throw new Error(`MCP smoke check failed: ${JSON.stringify(data.error)}`);
}
const tools = data.result?.tools || [];
if (!Array.isArray(tools) || tools.length === 0) {
throw new Error("MCP smoke check failed: tools/list returned no tools.");
}
return tools.length;
}
class UsageError extends Error {}
main().catch((error) => {
if (error instanceof UsageError) {
console.error(error.message);
console.error("Run ops-codex --help for usage.");
process.exit(2);
}
console.error(error.message || String(error));
process.exit(1);
});
@@ -0,0 +1,29 @@
{
"name": "@nodedc/ops-codex",
"version": "0.1.3",
"description": "Install scoped NODE.DC Ops MCP and separate read-only Ontology MCP access into local Codex.",
"type": "module",
"bin": {
"ops-codex": "./bin/nodedc-ops-codex.mjs",
"nodedc-ops-codex": "./bin/nodedc-ops-codex.mjs"
},
"engines": {
"node": ">=18"
},
"files": [
"bin/",
"README.md"
],
"keywords": [
"nodedc",
"ops",
"tasker",
"codex",
"mcp"
],
"license": "UNLICENSED",
"private": false,
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,37 @@
import { z } from "zod";
const optionalUrl = z.preprocess((value) => (value === "" ? undefined : value), z.string().url().optional());
const optionalSecret = z.preprocess((value) => (value === "" ? undefined : value), z.string().min(1).optional());
const npmPackageSpec = z.string().min(1).max(214).regex(/^\S+$/, "must not contain whitespace");
const configSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
HOST: z.string().min(1).default("0.0.0.0"),
PORT: z.coerce.number().int().min(1).max(65535).default(4100),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://ops-agents.local.nodedc"),
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(),
NODEDC_OPS_CODEX_INSTALL_CHANNEL: z.enum(["tarball", "registry"]).default("tarball"),
NODEDC_OPS_CODEX_NPM_SPEC: npmPackageSpec.default("@nodedc/ops-codex"),
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: z.coerce.number().int().min(300).max(86_400).default(43_200),
NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"),
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
NODEDC_ENGINE_INTERNAL_URL: z.string().url().default("http://172.22.0.222:3001"),
NODEDC_ONTOLOGY_CORE_URL: z.string().url().default("http://172.22.0.222:18104"),
NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN: optionalSecret,
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
DATABASE_URL: optionalUrl,
});
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const parsed = configSchema.safeParse(env);
if (!parsed.success) {
const details = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
throw new Error(`Invalid Agent Gateway configuration: ${details}`);
}
return parsed.data;
}
@@ -0,0 +1,56 @@
export const allowedAgentScopes = [
"workspace:read",
"project:read",
"project:create",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:attachment:write",
"issue:structured_blocks:write",
] as const;
export type AgentScope = (typeof allowedAgentScopes)[number];
export const deniedMvpCapabilities = [
"issue:delete",
"issue:archive",
"comment:delete",
"label:delete",
"state:create",
"state:delete",
"project:delete",
"workspace:settings",
"workspace:member:invite",
"workspace:member:remove",
"raw_tasker_api",
] as const;
export const taskAuthorPresetScopes: AgentScope[] = [
"workspace:read",
"project:read",
"project:create",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:attachment:write",
"issue:structured_blocks:write",
];
export const reporterPresetScopes: AgentScope[] = [
"workspace:read",
"project:read",
"issue:read",
"issue:update",
"issue:comment",
"issue:structured_blocks:write",
];
@@ -0,0 +1,849 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import type { AgentScope } from "../domain/scopes.js";
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
import type { TaskerClient } from "../tasker/client.js";
type JsonSchema = Record<string, unknown>;
export type McpToolRuntimeDefinition = {
name: string;
title: string;
description: string;
requiredScopes: AgentScope[];
inputSchema: JsonSchema;
annotations?: Record<string, unknown>;
};
export type McpToolResult = {
content: Array<{
type: "text";
text: string;
}>;
structuredContent?: unknown;
isError?: boolean;
};
type ExecuteToolDeps = {
agentsRepository?: AgentsRepository | null;
taskerClient: TaskerClient;
};
type ExecuteToolOptions = {
source?: "mcp" | "rest";
idempotencyKey?: string | null;
};
const emptyInputSchema = {
type: "object",
additionalProperties: false,
};
const projectInputSchema = {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["project_id"],
additionalProperties: false,
};
const projectAndIssueInputSchema = {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
};
const structuredBlocksJsonSchema = {
type: "array",
items: {
oneOf: [
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "text" },
title: {
type: "string",
description: "Visible block title. Put headings here, not inside body markdown.",
},
body: {
type: "string",
description: "Plain block body without a leading markdown heading.",
},
},
required: ["id", "type", "title", "body"],
additionalProperties: false,
},
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "checker" },
title: {
type: "string",
description: "Visible checklist title. Put headings here, not inside item text.",
},
items: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
checked: { type: "boolean" },
},
required: ["id", "text"],
additionalProperties: false,
},
},
},
required: ["id", "type", "title", "items"],
additionalProperties: false,
},
],
},
};
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
{
name: "tasker_get_agent_instructions",
title: "NODE.DC Ops: Get Agent Instructions",
description:
"Direct NODE.DC Ops MCP tool. Return effective Tasker card-writing rules, grants, scopes, and mode expectations. Use this before any Ops card read/write workflow.",
requiredScopes: ["workspace:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_list_projects",
title: "NODE.DC Ops: List Granted Projects",
description:
"Direct NODE.DC Ops MCP tool. List Tasker/Ops projects granted to the current Codex agent. Use this instead of Codex Apps workspace widgets.",
requiredScopes: ["project:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_get_project_context",
title: "NODE.DC Ops: Get Project Context",
description:
"Direct NODE.DC Ops MCP tool. Return states, labels, members, and card-writing context for one granted Tasker/Ops project.",
requiredScopes: ["project:read"],
inputSchema: projectInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_create_project",
title: "NODE.DC Ops: Create Project",
description:
"Direct NODE.DC Ops MCP write tool. Create a new Tasker project inside an explicitly granted workspace and extend the current agent grant to the created project.",
requiredScopes: ["project:create"],
inputSchema: {
type: "object",
properties: {
workspace_slug: { type: "string" },
name: { type: "string" },
identifier: {
type: "string",
description: "Optional 1-12 character Tasker project identifier. A unique identifier is generated when omitted.",
},
description: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["workspace_slug", "name"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_search_issues",
title: "NODE.DC Ops: Search Cards",
description:
"Direct NODE.DC Ops MCP tool. Search Tasker/Ops cards and work items inside one granted project. Use this for 'show cards', 'find cards', and project context requests.",
requiredScopes: ["issue:read"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
query: { type: "string" },
},
},
annotations: { readOnlyHint: true },
},
{
name: "tasker_get_issue",
title: "NODE.DC Ops: Get Card",
description:
"Direct NODE.DC Ops MCP tool. Return one granted Tasker/Ops card with structured blocks, labels, assignees, full comment bodies, and attachment metadata.",
requiredScopes: ["issue:read"],
inputSchema: projectAndIssueInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_create_issue",
title: "NODE.DC Ops: Create Card",
description:
"Direct NODE.DC Ops MCP write tool. Create a Tasker/Ops card with optional NODE.DC structured text/checker blocks.",
requiredScopes: ["issue:create"],
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["project_id", "title"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_issue",
title: "NODE.DC Ops: Update Card",
description:
"Direct NODE.DC Ops MCP write tool. Patch allowed Tasker/Ops card fields without delete, archive, or project transfer.",
requiredScopes: ["issue:update"],
inputSchema: {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_structured_blocks",
title: "NODE.DC Ops: Update Structured Blocks",
description:
"Direct NODE.DC Ops MCP write tool. Replace NODE.DC structured text/checker blocks in a Tasker/Ops card detail layout.",
requiredScopes: ["issue:update", "issue:structured_blocks:write"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "structured_blocks"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_move_issue",
title: "NODE.DC Ops: Move Card",
description: "Direct NODE.DC Ops MCP write tool. Move a Tasker/Ops card to an existing state in the same granted project.",
requiredScopes: ["issue:move"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
state_id: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "state_id"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_append_comment",
title: "NODE.DC Ops: Append Comment",
description: "Direct NODE.DC Ops MCP write tool. Append a comment to a granted Tasker/Ops card.",
requiredScopes: ["issue:comment"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
body: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "body"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_attach_file",
title: "NODE.DC Ops: Attach File",
description:
"Direct NODE.DC Ops MCP write tool. Attach a base64-encoded local file to a granted card through Tasker's existing storage, quota, and deduplication path.",
requiredScopes: ["issue:attachment:write"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
file_name: { type: "string", description: "File name only; paths are rejected." },
mime_type: { type: "string" },
content_base64: {
type: "string",
maxLength: 7000000,
contentEncoding: "base64",
description: "Base64 file content. Raw decoded size must not exceed 5 MiB.",
},
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "file_name", "mime_type", "content_base64"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_ensure_labels",
title: "NODE.DC Ops: Ensure Project Labels",
description:
"Direct NODE.DC Ops MCP write tool. Create missing labels in a granted project and return label ids for subsequent card labeling.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
labels: {
type: "array",
minItems: 1,
maxItems: 50,
items: {
type: "object",
properties: {
name: { type: "string" },
color: {
type: "string",
description: "Optional hex color in #RRGGBB format.",
},
},
required: ["name"],
additionalProperties: false,
},
},
idempotency_key: { type: "string" },
},
required: ["project_id", "labels"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: "tasker_set_issue_labels",
title: "NODE.DC Ops: Set Card Labels",
description: "Direct NODE.DC Ops MCP write tool. Replace card labels with existing or ensured labels from the granted project.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
label_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "label_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: "tasker_assign_issue",
title: "NODE.DC Ops: Assign Card",
description: "Direct NODE.DC Ops MCP write tool. Replace card assignees with existing members of the granted project.",
requiredScopes: ["issue:assign"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
member_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "member_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
];
const emptyArgsSchema = z.object({}).default({});
const projectArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const createProjectArgsSchema = z.object({
workspace_slug: z.string().min(1).max(255),
name: z.string().min(1).max(255),
identifier: z
.string()
.min(1)
.max(12)
.regex(/^[A-Za-z0-9_]+$/)
.optional(),
description: z.string().max(20_000).optional(),
});
const searchIssuesArgsSchema = projectArgsSchema.extend({
query: z.string().min(1).optional(),
});
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
const createIssueArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
idempotency_key: z.string().optional(),
});
const issueArgsSchema = z.object({
issue_id: z.string().min(1),
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const updateIssueArgsSchema = issueArgsSchema.extend({
title: z.string().min(1).max(500).optional(),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const structuredBlocksArgsSchema = issueArgsSchema.extend({
structured_blocks: structuredBlocksSchema,
});
const moveIssueArgsSchema = issueArgsSchema.extend({
state_id: z.string().min(1),
});
const commentArgsSchema = issueArgsSchema.extend({
body: z.string().min(1).max(20000),
});
const attachFileArgsSchema = issueArgsSchema
.extend({
file_name: z
.string()
.min(1)
.max(255)
.refine((value) => !/[\\/\0]/.test(value), "file_name must not contain a path"),
mime_type: z
.string()
.min(3)
.max(255)
.regex(/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/),
content_base64: z
.string()
.min(1)
.max(7_000_000)
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
})
.superRefine((input, context) => {
if (Buffer.byteLength(input.content_base64, "base64") > 5 * 1024 * 1024) {
context.addIssue({
code: "custom",
path: ["content_base64"],
message: "Decoded attachment must not exceed 5 MiB.",
});
}
});
const ensureLabelsArgsSchema = projectArgsSchema.extend({
labels: z
.array(
z.object({
name: z.string().min(1).max(255),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
})
)
.min(1)
.max(50),
});
const labelsArgsSchema = issueArgsSchema.extend({
label_ids: z.array(z.string().min(1)).default([]),
});
const assigneesArgsSchema = issueArgsSchema.extend({
member_ids: z.array(z.string().min(1)).default([]),
});
export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] {
return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope)));
}
export async function executeMcpTool(
session: AgentSessionRecord,
name: string,
rawArguments: unknown,
deps: ExecuteToolDeps,
options: ExecuteToolOptions = {}
): Promise<McpToolResult> {
const tool = mcpRuntimeTools.find((candidate) => candidate.name === name);
if (!tool) {
throw new Error(`Unknown MCP tool: ${name}`);
}
const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey);
const isWriteTool = tool.annotations?.readOnlyHint !== true;
if (!isWriteTool) {
return executeMcpToolOnce(session, name, args, deps);
}
if (!deps.agentsRepository) {
throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503);
}
if (!idempotencyKey) {
throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400);
}
const requestHash = hashToolRequest(name, args);
const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash);
if (claim.status === "replay") {
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
});
return claim.responseBody as McpToolResult;
}
if (claim.status === "conflict") {
throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409);
}
if (claim.status === "in_progress") {
throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, {
lockedUntil: claim.lockedUntil,
});
}
try {
const result = await executeMcpToolOnce(session, name, args, deps);
await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
arguments: summarizeToolArguments(args),
});
return result;
} catch (error) {
await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
error: error instanceof Error ? error.name : "unknown_error",
message: error instanceof Error ? error.message : "Unknown tool execution error.",
});
throw error;
}
}
async function executeMcpToolOnce(
session: AgentSessionRecord,
name: string,
args: unknown,
deps: ExecuteToolDeps
): Promise<McpToolResult> {
switch (name) {
case "tasker_get_agent_instructions":
emptyArgsSchema.parse(args);
requireScope(session, "workspace:read");
return asToolResult(buildAgentInstructions(session));
case "tasker_list_projects":
emptyArgsSchema.parse(args);
requireScope(session, "project:read");
return asToolResult(await deps.taskerClient.listGrantedProjects(session));
case "tasker_get_project_context": {
const input = projectArgsSchema.parse(args);
requireScope(session, "project:read");
requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug });
return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug));
}
case "tasker_create_project": {
const input = createProjectArgsSchema.parse(args);
if (session.grantSource !== "agent") {
throw new ForbiddenError("Project creation requires an agent-scoped local token.");
}
const sourceGrant = requireWorkspaceScope(session, "project:create", input.workspace_slug);
if (!deps.agentsRepository) {
throw new ToolExecutionInputError("agent_repository_unavailable", "Agent Gateway persistence is required.", 503);
}
const payload = await deps.taskerClient.createProject(session, input);
const createdProject = extractCreatedProject(payload);
await deps.agentsRepository.upsertGrant(session.agent.id, {
workspaceSlug: input.workspace_slug,
projectId: createdProject.id,
scopes: sourceGrant.scopes,
mode: sourceGrant.mode,
createdByUserId: session.agent.ownerUserId,
});
return asToolResult(payload);
}
case "tasker_search_issues": {
const input = searchIssuesArgsSchema.parse(args);
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query));
}
case "tasker_get_issue": {
const input = issueArgsSchema.parse(args);
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.getIssue(session, input.issue_id, input));
}
case "tasker_create_issue": {
const input = createIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.createIssue(session, input));
}
case "tasker_update_issue": {
const input = updateIssueArgsSchema.parse(args);
if (input.structured_blocks) {
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
} else {
requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug);
}
return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input));
}
case "tasker_update_structured_blocks": {
const input = structuredBlocksArgsSchema.parse(args);
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
return asToolResult(
await deps.taskerClient.updateIssue(session, input.issue_id, {
project_id: input.project_id,
workspace_slug: input.workspace_slug,
structured_blocks: input.structured_blocks,
})
);
}
case "tasker_move_issue": {
const input = moveIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input));
}
case "tasker_append_comment": {
const input = commentArgsSchema.parse(args);
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
}
case "tasker_attach_file": {
const input = attachFileArgsSchema.parse(args);
requireToolAccess(session, "issue:attachment:write", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.attachFile(session, input.issue_id, input));
}
case "tasker_ensure_labels": {
const input = ensureLabelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.ensureLabels(session, input));
}
case "tasker_set_issue_labels": {
const input = labelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input));
}
case "tasker_assign_issue": {
const input = assigneesArgsSchema.parse(args);
requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input));
}
default:
throw new Error(`Unknown MCP tool: ${name}`);
}
}
function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean {
return session.grants.some((grant) => grant.scopes.includes(scope));
}
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
requireProjectScopes(session, projectId, workspaceSlug, [scope]);
}
function requireWorkspaceScope(session: AgentSessionRecord, scope: AgentScope, workspaceSlug: string) {
requireScope(session, scope);
const grant = session.grants.find(
(candidate) => candidate.workspaceSlug === workspaceSlug && candidate.scopes.includes(scope)
);
if (!grant) {
throw new ForbiddenError(`Grant for workspace does not include required scope: ${scope}.`);
}
return grant;
}
function requireProjectScopes(
session: AgentSessionRecord,
projectId: string,
workspaceSlug: string | null | undefined,
scopes: AgentScope[]
): void {
for (const scope of scopes) {
requireScope(session, scope);
}
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
for (const scope of scopes) {
if (!grant.scopes.includes(scope)) {
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
}
}
}
function asToolResult(payload: unknown): McpToolResult {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
structuredContent: payload,
isError: false,
};
}
function extractCreatedProject(payload: unknown): { id: string } {
if (!isPlainRecord(payload) || !isPlainRecord(payload.project) || typeof payload.project.id !== "string") {
throw new ToolExecutionInputError(
"tasker_project_response_invalid",
"Tasker did not return the created project id.",
502
);
}
return { id: payload.project.id };
}
export class ToolExecutionInputError extends Error {
constructor(
readonly code: string,
message: string,
readonly httpStatus: number,
readonly details?: Record<string, unknown>
) {
super(message);
this.name = "ToolExecutionInputError";
}
}
function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } {
if (!isPlainRecord(rawArguments)) {
return {
args: rawArguments ?? {},
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey),
};
}
const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments;
return {
args,
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)),
};
}
function normalizeIdempotencyKey(value?: string | null): string | null {
const normalized = value?.trim();
if (!normalized) {
return null;
}
if (normalized.length > 200) {
throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400);
}
return normalized;
}
function hashToolRequest(name: string, args: unknown): string {
return createHash("sha256").update(stableStringify({ name, args })).digest("hex");
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (isPlainRecord(value)) {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function summarizeToolArguments(args: unknown): Record<string, unknown> {
if (!isPlainRecord(args)) {
return {};
}
return {
workspace_slug: args.workspace_slug,
project_id: args.project_id,
issue_id: args.issue_id,
state_id: args.state_id,
project_name: args.name,
file_name: args.file_name,
attachment_bytes:
typeof args.content_base64 === "string" ? Buffer.byteLength(args.content_base64, "base64") : undefined,
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
has_structured_blocks: Array.isArray(args.structured_blocks),
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
return {
agent: {
id: session.agent.id,
display_name: session.agent.displayName,
owner_user_id: session.agent.ownerUserId,
},
grants: session.grants.map((grant) => ({
workspace_slug: grant.workspaceSlug,
project_id: grant.projectId,
mode: grant.mode,
scopes: grant.scopes,
})),
grant_source: session.grantSource,
rules: {
card_structure: [
"Keep the main issue description concise and conceptual.",
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
"Every structured text block must use its title field for the heading; do not duplicate markdown headings like ## Status inside the body.",
"Wrong structured text block: title omitted and body starts with ## Текущая архитектура. Correct: title is Текущая архитектура and body contains only the section content.",
"Use checker blocks with explicit titles for short verifiable phase items.",
"After implementation, add a factual implementation block with files touched and validation performed.",
],
labels: [
"Use tasker_ensure_labels before setting a label that is not already present in project context.",
"Use tasker_set_issue_labels with label ids returned by project context or tasker_ensure_labels.",
],
hard_limits: [
"Do not delete or archive issues.",
"Create projects only through tasker_create_project when the effective workspace grant includes project:create.",
"Attach files only through tasker_attach_file; never upload from arbitrary URLs or server paths.",
"Do not create states, workspace invites, or workspace settings changes.",
"Only create labels through tasker_ensure_labels inside granted projects.",
"Only assign existing project members.",
"Only use projects and workspaces present in effective grants.",
],
reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.",
},
};
}
@@ -0,0 +1,81 @@
import type { FastifyInstance } from "fastify";
import { DatabaseNotConfiguredError } from "../db/pool.js";
import type { AgentsRepository } from "../repositories/agents.js";
import { parseBearerToken } from "../security/bearer.js";
const MCP_PROTOCOL_VERSION = "2025-06-18";
type OntologyRouteDeps = {
agentsRepository: AgentsRepository | null;
ontologyCoreUrl: string;
ontologyCoreAccessToken?: string;
};
export async function registerOntologyGatewayRoutes(app: FastifyInstance, deps: OntologyRouteDeps): Promise<void> {
app.post("/ontology-mcp", async (request, reply) => {
if (!deps.agentsRepository) {
throw new DatabaseNotConfiguredError();
}
const token = parseBearerToken(request.headers.authorization);
const session = await deps.agentsRepository.findActiveSessionByToken(token, "ontology");
if (!session) {
return reply.status(401).send({
ok: false,
error: "ontology_agent_unauthorized",
message: "Ontology token is inactive, expired, revoked, or has the wrong purpose.",
});
}
const upstreamToken = deps.ontologyCoreAccessToken;
if (!upstreamToken) {
return reply.status(503).send({
ok: false,
error: "ontology_proxy_not_configured",
message: "Ontology Core access token is not configured.",
});
}
let upstream: Response;
try {
upstream = await fetch(new URL("/mcp", deps.ontologyCoreUrl), {
method: "POST",
redirect: "manual",
headers: {
Accept: readHeader(request.headers.accept) ?? "application/json, text/event-stream",
Authorization: `Bearer ${upstreamToken}`,
"Content-Type": "application/json",
"MCP-Protocol-Version": readHeader(request.headers["mcp-protocol-version"]) ?? MCP_PROTOCOL_VERSION,
},
body: JSON.stringify(request.body ?? {}),
signal: AbortSignal.timeout(60_000),
});
} catch {
return reply.status(503).send({
ok: false,
error: "ontology_proxy_unavailable",
message: "Ontology Core MCP is unavailable.",
});
}
const payload = await upstream.text();
reply.status(upstream.status);
reply.header("Cache-Control", "no-store");
reply.header("Content-Type", upstream.headers.get("content-type") ?? "application/json; charset=utf-8");
for (const header of ["mcp-protocol-version", "mcp-session-id", "vary"]) {
const value = upstream.headers.get(header);
if (value) {
reply.header(header, value);
}
}
return reply.send(payload);
});
}
function readHeader(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0];
}
return value;
}
@@ -0,0 +1,204 @@
import type { FastifyInstance } from "fastify";
import { executeMcpTool } from "../mcp/tool-runtime.js";
import type { AgentsRepository } from "../repositories/agents.js";
import type { TaskerClient } from "../tasker/client.js";
import { authenticateAgent } from "./session.js";
type ToolRouteDeps = {
agentsRepository: AgentsRepository | null;
taskerClient: TaskerClient;
};
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
app.post("/api/v1/tools/projects", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_create_project", request.body, deps, toolOptions(request));
return result.structuredContent;
});
app.get("/api/v1/tools/projects", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps, toolOptions(request));
return result.structuredContent;
});
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { projectId: string };
const query = request.query as { workspace_slug?: string };
const result = await executeMcpTool(
session,
"tasker_get_project_context",
{
project_id: params.projectId,
workspace_slug: query.workspace_slug,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/projects/:projectId/labels/ensure", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { projectId: string };
const result = await executeMcpTool(
session,
"tasker_ensure_labels",
{
...requestBodyRecord(request.body),
project_id: params.projectId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.get("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
const result = await executeMcpTool(session, "tasker_search_issues", query, deps, toolOptions(request));
return result.structuredContent;
});
app.post("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps, toolOptions(request));
return result.structuredContent;
});
app.get("/api/v1/tools/issues/:issueId", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const query = request.query as { project_id?: string; workspace_slug?: string };
const result = await executeMcpTool(
session,
"tasker_get_issue",
{ ...query, issue_id: params.issueId },
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_update_issue",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_move_issue",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_append_comment",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/attachments", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_attach_file",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_set_issue_labels",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_assign_issue",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
}
function toolOptions(request: { headers: Record<string, string | string[] | undefined> }): { source: "rest"; idempotencyKey: string | null } {
return {
source: "rest",
idempotencyKey: readHeader(request.headers["idempotency-key"]),
};
}
function requestBodyRecord(body: unknown): Record<string, unknown> {
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
return body as Record<string, unknown>;
}
return {};
}
function readHeader(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}
@@ -0,0 +1,25 @@
import { createHash, randomBytes } from "node:crypto";
const TOKEN_PREFIX = "ndcag";
const ONTOLOGY_TOKEN_PREFIX = "ndcao";
const SETUP_CODE_PREFIX = "ndcsetup";
export function generateAgentToken(): string {
return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
}
export function generateOntologyAgentToken(): string {
return `${ONTOLOGY_TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
}
export function hashAgentToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function generateAgentSetupCode(): string {
return `${SETUP_CODE_PREFIX}_${randomBytes(32).toString("base64url")}`;
}
export function hashAgentSetupCode(code: string): string {
return createHash("sha256").update(code).digest("hex");
}
@@ -0,0 +1,332 @@
import type { AgentSessionRecord } from "../repositories/agents.js";
export class TaskerAdapterNotConfiguredError extends Error {
constructor() {
super("NODEDC_INTERNAL_ACCESS_TOKEN is required for Tasker adapter calls.");
this.name = "TaskerAdapterNotConfiguredError";
}
}
export class TaskerAdapterError extends Error {
constructor(
message: string,
readonly statusCode: number,
readonly payload: unknown
) {
super(message);
this.name = "TaskerAdapterError";
}
}
export class TaskerAdapterUnavailableError extends Error {
constructor(readonly causeError: unknown) {
super("Tasker internal adapter is unavailable.");
this.name = "TaskerAdapterUnavailableError";
}
}
export type TaskerClientConfig = {
baseUrl: string;
internalAccessToken?: string;
};
export type TaskerAgentContext = {
agentId: string;
ownerUserId: string;
tokenId: string;
};
export type GrantedProjectInput = {
workspace_slug: string;
project_id: string | null;
mode: string;
scopes: string[];
};
export type ListGrantedProjectsInput = {
grants: GrantedProjectInput[];
};
export type CreateIssueInput = {
project_id: string;
workspace_slug?: string | null;
title: string;
description?: string;
priority?: "none" | "low" | "medium" | "high" | "urgent";
structured_blocks?: unknown[];
};
export type CreateProjectInput = {
workspace_slug: string;
name: string;
identifier?: string;
description?: string;
};
export type GetIssueInput = {
project_id: string;
workspace_slug?: string | null;
};
export type UpdateIssueInput = {
project_id: string;
workspace_slug?: string | null;
title?: string;
description?: string;
priority?: "none" | "low" | "medium" | "high" | "urgent";
structured_blocks?: unknown[];
};
export type MoveIssueInput = {
project_id: string;
workspace_slug?: string | null;
state_id: string;
};
export type CommentInput = {
project_id: string;
workspace_slug?: string | null;
body: string;
};
export type AttachFileInput = {
project_id: string;
workspace_slug?: string | null;
file_name: string;
mime_type: string;
content_base64: string;
};
export type SetLabelsInput = {
project_id: string;
workspace_slug?: string | null;
label_ids: string[];
};
export type EnsureLabelsInput = {
project_id: string;
workspace_slug?: string | null;
labels: Array<{
name: string;
color?: string;
}>;
};
export type AssignIssueInput = {
project_id: string;
workspace_slug?: string | null;
member_ids: string[];
};
export class TaskerClient {
constructor(private readonly config: TaskerClientConfig) {}
async listGrantedProjects(session: AgentSessionRecord): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/projects/resolve", {
method: "POST",
session,
body: {
grants: session.grants.map((grant) => ({
workspace_slug: grant.workspaceSlug,
project_id: grant.projectId,
mode: grant.mode,
scopes: grant.scopes,
})),
} satisfies ListGrantedProjectsInput,
});
}
async getProjectContext(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null): Promise<unknown> {
const searchParams = new URLSearchParams();
if (workspaceSlug) {
searchParams.set("workspace_slug", workspaceSlug);
}
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(projectId)}/context?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async createProject(session: AgentSessionRecord, input: CreateProjectInput): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/projects", {
method: "POST",
session,
body: input,
});
}
async listIssues(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null, query?: string): Promise<unknown> {
const searchParams = new URLSearchParams({ project_id: projectId });
if (workspaceSlug) {
searchParams.set("workspace_slug", workspaceSlug);
}
if (query) {
searchParams.set("query", query);
}
return this.request(`/api/internal/nodedc/agent/issues?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async getIssue(session: AgentSessionRecord, issueId: string, input: GetIssueInput): Promise<unknown> {
const searchParams = new URLSearchParams({ project_id: input.project_id });
if (input.workspace_slug) {
searchParams.set("workspace_slug", input.workspace_slug);
}
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async createIssue(session: AgentSessionRecord, input: CreateIssueInput): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/issues", {
method: "POST",
session,
body: input,
});
}
async updateIssue(session: AgentSessionRecord, issueId: string, input: UpdateIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}`, {
method: "PATCH",
session,
body: input,
});
}
async moveIssue(session: AgentSessionRecord, issueId: string, input: MoveIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/move`, {
method: "POST",
session,
body: input,
});
}
async appendComment(session: AgentSessionRecord, issueId: string, input: CommentInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/comments`, {
method: "POST",
session,
body: input,
});
}
async attachFile(session: AgentSessionRecord, issueId: string, input: AttachFileInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/attachments`, {
method: "POST",
session,
body: input,
});
}
async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
method: "PUT",
session,
body: input,
});
}
async ensureLabels(session: AgentSessionRecord, input: EnsureLabelsInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(input.project_id)}/labels/ensure`, {
method: "POST",
session,
body: input,
});
}
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
method: "PUT",
session,
body: input,
});
}
private async request(
path: string,
input: {
method: "GET" | "POST" | "PATCH" | "PUT";
session: AgentSessionRecord;
body?: unknown;
}
): Promise<unknown> {
if (!this.config.internalAccessToken) {
throw new TaskerAdapterNotConfiguredError();
}
const response = await this.fetchTasker(path, input);
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new TaskerAdapterError("Tasker internal adapter request failed.", response.status, payload);
}
return payload;
}
private async fetchTasker(
path: string,
input: {
method: "GET" | "POST" | "PATCH" | "PUT";
session: AgentSessionRecord;
body?: unknown;
}
): Promise<Response> {
try {
const requestBody = attachAgentMetadata(input.session, input.body);
return await fetch(new URL(path, this.config.baseUrl), {
method: input.method,
headers: {
Authorization: `Bearer ${this.config.internalAccessToken}`,
"Content-Type": "application/json",
"X-NODEDC-Agent-Id": input.session.agent.id,
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
"X-NODEDC-Agent-Token-Id": input.session.token.id,
},
body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
});
} catch (error) {
throw new TaskerAdapterUnavailableError(error);
}
}
}
function attachAgentMetadata(session: AgentSessionRecord, body: unknown): unknown {
if (body === undefined || !isPlainRecord(body)) {
return body;
}
return {
...body,
_agent: {
display_name: session.agent.displayName,
avatar_url: session.agent.avatarUrl,
},
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}