diff --git a/README.md b/README.md index 8a4f7a4..6d09e18 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are - Internal REST endpoints for agent profile, grant, and token lifecycle. - Opaque agent tokens are generated once and stored only as SHA-256 hashes. - Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls. +- Agent setup endpoint returns an MCP config template and AGENTS.md instruction pack without echoing the raw token. - Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter. - MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints. - Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes. @@ -75,6 +76,20 @@ curl http://127.0.0.1:4100/api/v1/agent-session \ Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added. +Generate a local Codex setup packet: + +```bash +curl http://127.0.0.1:4100/api/v1/agent-session/setup \ + -H "Authorization: Bearer $TOKEN" | jq -r .setup.agents_md +``` + +The setup packet includes: + +- MCP endpoint and header template with `` placeholder; +- available tools for the current grants; +- AGENTS.md rules for Tasker card writing, no-delete boundary, and required `idempotency_key`; +- no raw token echo. + Call MCP tools through the JSON-RPC endpoint: ```bash diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index a1e20c3..10be5db 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -111,7 +111,7 @@ Acceptance: - local Codex can move card state; - local Codex cannot delete/archive. -Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, idempotency handling, audit events, and Tasker adapter calls. `npm run smoke:mcp:e2e` verifies real local Tasker writes and idempotent replay. +Status: initial product slice implemented. `/mcp` supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call`. REST product endpoints and MCP tools share the same runtime, scope checks, grant checks, idempotency handling, audit events, and Tasker adapter calls. `/api/v1/agent-session/setup` returns the MCP config template and generated AGENTS.md instruction pack. `npm run smoke:mcp:e2e` verifies real local Tasker writes and idempotent replay. ## Phase 6. Agent identity diff --git a/docs/MCP_TOOLS_CONTRACT.md b/docs/MCP_TOOLS_CONTRACT.md index cc2947f..a83c46e 100644 --- a/docs/MCP_TOOLS_CONTRACT.md +++ b/docs/MCP_TOOLS_CONTRACT.md @@ -336,6 +336,19 @@ tasker_raw_api_request `tasker_get_agent_instructions` should include the effective card guide. The local Codex setup file should instruct the agent to call this tool before planning Tasker changes. +Agent Gateway also exposes: + +```text +GET /api/v1/agent-session/setup +``` + +This endpoint is authenticated by the same bearer token and returns: + +- MCP server config template with `` placeholder; +- tools available to the current agent grants; +- generated AGENTS.md content with NODE.DC Tasker operating rules; +- no raw token echo. + Minimum instruction: ```text diff --git a/src/app.ts b/src/app.ts index 7ae9fa4..ad4b548 100644 --- a/src/app.ts +++ b/src/app.ts @@ -115,7 +115,7 @@ export async function buildApp(config: AppConfig): Promise { }); await registerHealthRoutes(app, config, pool); - await registerAgentRoutes(app, { agentsRepository }); + await registerAgentRoutes(app, { agentsRepository, publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL }); await registerToolRoutes(app, { agentsRepository, taskerClient }); await registerMcpRoutes(app, { agentsRepository, taskerClient }); diff --git a/src/routes/agents.ts b/src/routes/agents.ts index e50c477..9df0dd7 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js"; import { DatabaseNotConfiguredError } from "../db/pool.js"; +import { getToolsForSession } from "../mcp/tool-runtime.js"; import { mcpToolDefinitions } from "../mcp/tools.js"; import type { AgentGrantRecord, AgentRecord, AgentSessionRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js"; import { parseBearerToken, UnauthorizedError } from "../security/bearer.js"; @@ -10,6 +11,7 @@ import { generateAgentToken } from "../security/tokens.js"; type AgentRouteDeps = { agentsRepository: AgentsRepository | null; + publicUrl: string; }; const agentParamsSchema = z.object({ @@ -61,13 +63,7 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute })); app.get("/api/v1/agent-session", async (request) => { - const repository = requireRepository(deps); - const token = parseBearerToken(request.headers.authorization); - const session = await repository.findActiveSessionByToken(token); - - if (!session) { - throw new UnauthorizedError("Agent token is inactive, expired, or revoked."); - } + const session = await authenticateAgentSession(request.headers.authorization, deps); return { ok: true, @@ -75,6 +71,15 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }; }); + app.get("/api/v1/agent-session/setup", async (request) => { + const session = await authenticateAgentSession(request.headers.authorization, deps); + + return { + ok: true, + setup: buildSetupPacket(session, deps.publicUrl), + }; + }); + app.post("/api/v1/agents", async (request, reply) => { const repository = requireRepository(deps); const body = createAgentBodySchema.parse(request.body); @@ -245,6 +250,18 @@ function sendNotFound(reply: FastifyReply, error: string): FastifyReply { }); } +async function authenticateAgentSession(authorization: string | undefined, deps: AgentRouteDeps): Promise { + const repository = requireRepository(deps); + const token = parseBearerToken(authorization); + const session = await repository.findActiveSessionByToken(token); + + if (!session) { + throw new UnauthorizedError("Agent token is inactive, expired, or revoked."); + } + + return session; +} + function serializeAgent(agent: AgentRecord): Record { return { id: agent.id, @@ -293,3 +310,84 @@ function serializeAgentSession(session: AgentSessionRecord): Record grant.mode))], }; } + +function buildSetupPacket(session: AgentSessionRecord, publicUrl: string): Record { + const endpoint = new URL("/mcp", publicUrl).toString(); + const tokenPlaceholder = ""; + + return { + mcp_server: { + name: "nodedc_tasker", + transport: "streamable_http_json_rpc", + url: endpoint, + headers: { + Authorization: `Bearer ${tokenPlaceholder}`, + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-06-18", + }, + }, + codex_config_template: { + mcp_servers: { + nodedc_tasker: { + url: endpoint, + headers: { + Authorization: `Bearer ${tokenPlaceholder}`, + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-06-18", + }, + }, + }, + }, + available_tools: getToolsForSession(session).map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + required_scopes: tool.requiredScopes, + })), + agents_md: buildAgentsMd(session, endpoint), + }; +} + +function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string { + const grants = session.grants + .map((grant) => `- workspace=${grant.workspaceSlug}; project=${grant.projectId ?? "*"}; mode=${grant.mode}; scopes=${grant.scopes.join(",")}`) + .join("\n"); + const tools = getToolsForSession(session) + .map((tool) => `- ${tool.name}: ${tool.description}`) + .join("\n"); + + return [ + "# NODE.DC Tasker Agent Rules", + "", + `MCP endpoint: ${endpoint}`, + "", + "## Startup", + "", + "- Call `tasker_get_agent_instructions` before creating or changing Tasker cards.", + "- Call `tasker_list_projects` and `tasker_get_project_context` before writing into a project.", + "- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.", + "", + "## Write Safety", + "", + "- Every write tool call must include a unique `idempotency_key`.", + "- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.", + "- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.", + "- Only assign existing project members returned by project context.", + "", + "## Card Writing", + "", + "- Keep card titles concise and operational.", + "- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.", + "- Put short verifiable work items into checker blocks.", + "- After code work, update the related card with factual files touched and validation performed.", + "", + "## Effective Grants", + "", + grants || "- No grants available.", + "", + "## Available Tools", + "", + tools || "- No tools available for current grants.", + "", + ].join("\n"); +} diff --git a/src/scripts/smoke-gateway.ts b/src/scripts/smoke-gateway.ts index 9d5817c..b8ce8b6 100644 --- a/src/scripts/smoke-gateway.ts +++ b/src/scripts/smoke-gateway.ts @@ -71,6 +71,19 @@ try { }); assertStatus(sessionResponse.statusCode, 200, sessionResponse.body); + const setupResponse = await app.inject({ + method: "GET", + url: "/api/v1/agent-session/setup", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + assertStatus(setupResponse.statusCode, 200, setupResponse.body); + const setupPayload = JSON.parse(setupResponse.body); + assert(setupPayload.setup?.mcp_server?.url?.endsWith("/mcp"), "setup packet includes MCP endpoint"); + assert(setupPayload.setup?.agents_md?.includes("idempotency_key"), "setup packet includes idempotency rules"); + assert(!JSON.stringify(setupPayload).includes(token), "setup packet does not echo raw token"); + const deniedCreateResponse = await app.inject({ method: "POST", url: "/api/v1/tools/issues", @@ -136,6 +149,7 @@ try { token_prefix: token.split("_")[0], checks: { session_auth: "passed", + setup_packet: "passed", denied_without_scope: "passed", idempotency_required: "passed", allowed_request_reaches_tasker_boundary: "passed", @@ -154,3 +168,9 @@ function assertStatus(actual: number, expected: number, body: string): void { throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`); } } + +function assert(condition: unknown, message: string): void { + if (!condition) { + throw new Error(`Gateway smoke assertion failed: ${message}`); + } +}