@wardby/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -5
- package/README.md +6 -1
- package/deploy/local/docker-compose.quickstart-coding.yml +6 -0
- package/dist/claude-coding-worker/driver.d.ts +12 -0
- package/dist/claude-coding-worker/driver.js +29 -7
- package/dist/claude-coding-worker/main.js +5 -1
- package/dist/claude-coding-worker/sdk.js +17 -1
- package/dist/claude-plugin/command.d.ts +61 -0
- package/dist/claude-plugin/command.js +376 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +2 -1
- package/dist/cli.js +47 -3
- package/dist/coding/code-host.d.ts +16 -0
- package/dist/coding/code-host.js +60 -0
- package/dist/coding/mcp-token.d.ts +12 -0
- package/dist/coding/mcp-token.js +26 -0
- package/dist/coding/protocol.js +2 -0
- package/dist/coding/redact.js +6 -1
- package/dist/coding/transcript/recorder.d.ts +5 -4
- package/dist/coding/transcript/recorder.js +1 -1
- package/dist/coding-worker/debug-trace.d.ts +3 -1
- package/dist/coding-worker/debug-trace.js +4 -2
- package/dist/coding-worker/driver.js +8 -1
- package/dist/coding-worker/main.js +5 -1
- package/dist/coding-worker/mcp-config.d.ts +27 -0
- package/dist/coding-worker/mcp-config.js +32 -0
- package/dist/coding-worker/sdk.d.ts +36 -0
- package/dist/coding-worker/sdk.js +28 -1
- package/dist/coding-worker/types.d.ts +4 -0
- package/dist/config/mcp-call-limit.d.ts +8 -0
- package/dist/config/mcp-call-limit.js +18 -0
- package/dist/config/mcp-servers.d.ts +8 -0
- package/dist/config/mcp-servers.js +12 -0
- package/dist/config/test-simulators.d.ts +10 -0
- package/dist/config/test-simulators.js +28 -0
- package/dist/config/tool-timeout.d.ts +1 -0
- package/dist/config/tool-timeout.js +18 -0
- package/dist/core/coding-mcp.d.ts +36 -0
- package/dist/core/coding-mcp.js +253 -0
- package/dist/core/coding-repository.d.ts +46 -0
- package/dist/core/coding-repository.js +92 -0
- package/dist/core/delegation-slots.d.ts +27 -0
- package/dist/core/delegation-slots.js +44 -0
- package/dist/core/dispatch.d.ts +6 -2
- package/dist/core/dispatch.js +36 -11
- package/dist/core/error-code.js +1 -0
- package/dist/core/grants.d.ts +28 -3
- package/dist/core/grants.js +46 -4
- package/dist/core/host-events.js +7 -3
- package/dist/core/host-status.js +2 -2
- package/dist/core/issue-status.js +4 -3
- package/dist/core/mark-ready.d.ts +1 -1
- package/dist/core/mark-ready.js +1 -1
- package/dist/core/mcp-client/client.d.ts +47 -0
- package/dist/core/mcp-client/client.js +141 -0
- package/dist/core/mcp-client/jsonrpc.d.ts +15 -0
- package/dist/core/mcp-client/jsonrpc.js +54 -0
- package/dist/core/mcp-client/results.d.ts +13 -0
- package/dist/core/mcp-client/results.js +42 -0
- package/dist/core/mcp-servers.d.ts +90 -0
- package/dist/core/mcp-servers.js +230 -0
- package/dist/core/mcp-tools.d.ts +108 -0
- package/dist/core/mcp-tools.js +287 -0
- package/dist/core/merge-order-check.js +38 -19
- package/dist/core/notification-dispatcher.js +2 -1
- package/dist/core/related-pull-requests.js +26 -10
- package/dist/core/review-fix.js +2 -2
- package/dist/core/review-host-tools.js +3 -1
- package/dist/core/runner.d.ts +66 -16
- package/dist/core/runner.js +281 -73
- package/dist/core/secret-broker-config.d.ts +2 -0
- package/dist/core/secret-broker-config.js +1 -1
- package/dist/core/secret-guard.d.ts +5 -2
- package/dist/core/secret-guard.js +22 -7
- package/dist/core/secrets.d.ts +11 -1
- package/dist/core/secrets.js +27 -1
- package/dist/core/serializable-retry.d.ts +18 -0
- package/dist/core/serializable-retry.js +43 -0
- package/dist/core/tool-admin.d.ts +5 -1
- package/dist/core/tool-admin.js +7 -2
- package/dist/core/tool-names.d.ts +13 -0
- package/dist/core/tool-names.js +20 -0
- package/dist/core/webhooks.d.ts +4 -0
- package/dist/core/webhooks.js +68 -49
- package/dist/core/workflow-run-events.js +2 -1
- package/dist/generated/prisma/browser.d.ts +31 -0
- package/dist/generated/prisma/client.d.ts +31 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +50 -50
- package/dist/generated/prisma/internal/class.d.ts +66 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +530 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +75 -2
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +79 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +75 -2
- package/dist/generated/prisma/models/Agent.d.ts +271 -0
- package/dist/generated/prisma/models/AgentMcpServer.d.ts +1412 -0
- package/dist/generated/prisma/models/AgentMcpServer.js +1 -0
- package/dist/generated/prisma/models/AgentRepository.d.ts +40 -1
- package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
- package/dist/generated/prisma/models/CodingAgentProfile.d.ts +0 -7
- package/dist/generated/prisma/models/CodingProxySession.d.ts +119 -2
- package/dist/generated/prisma/models/CodingRun.d.ts +76 -1
- package/dist/generated/prisma/models/HostProvider.d.ts +2176 -0
- package/dist/generated/prisma/models/HostProvider.js +1 -0
- package/dist/generated/prisma/models/HostProviderCredential.d.ts +1285 -0
- package/dist/generated/prisma/models/HostProviderCredential.js +1 -0
- package/dist/generated/prisma/models/HostProviderOperation.d.ts +1470 -0
- package/dist/generated/prisma/models/HostProviderOperation.js +1 -0
- package/dist/generated/prisma/models/HostProviderTrustedAccount.d.ts +1145 -0
- package/dist/generated/prisma/models/HostProviderTrustedAccount.js +1 -0
- package/dist/generated/prisma/models/McpServer.d.ts +1500 -0
- package/dist/generated/prisma/models/McpServer.js +1 -0
- package/dist/generated/prisma/models/NativeGatewayCall.d.ts +39 -1
- package/dist/generated/prisma/models/Principal.d.ts +167 -0
- package/dist/generated/prisma/models/ResourceGrant.d.ts +1 -1
- package/dist/generated/prisma/models/Secret.d.ts +387 -0
- package/dist/generated/prisma/models/Tool.d.ts +223 -1
- package/dist/generated/prisma/models.d.ts +6 -0
- package/dist/help/search.js +12 -3
- package/dist/help-index.json +719 -47
- package/dist/import/create.js +16 -1
- package/dist/import/neutral-schema.d.ts +1 -0
- package/dist/import/neutral-schema.js +2 -0
- package/dist/mcp/auth/grants-cli.d.ts +6 -4
- package/dist/mcp/auth/grants-cli.js +42 -3
- package/dist/mcp/auth/resource-server.d.ts +3 -1
- package/dist/mcp/auth/resource-server.js +17 -2
- package/dist/mcp/context.d.ts +3 -0
- package/dist/mcp/index.js +11 -0
- package/dist/mcp/tools/agents.js +100 -15
- package/dist/mcp/tools/grants.js +66 -28
- package/dist/mcp/tools/host-providers.d.ts +2 -0
- package/dist/mcp/tools/host-providers.js +215 -0
- package/dist/mcp/tools/mcp-servers.d.ts +26 -0
- package/dist/mcp/tools/mcp-servers.js +481 -0
- package/dist/mcp/tools/repositories.js +50 -10
- package/dist/mcp/tools/runs.js +178 -18
- package/dist/mcp/tools/secret-elicitation.d.ts +2 -0
- package/dist/mcp/tools/secret-elicitation.js +25 -2
- package/dist/mcp/tools/secrets.js +38 -4
- package/dist/mcp/tools/tools.d.ts +6 -4
- package/dist/mcp/tools/tools.js +40 -8
- package/dist/mcp/tools/trigger.js +28 -6
- package/dist/mcp/transport/http-limits.d.ts +2 -0
- package/dist/mcp/transport/http-limits.js +6 -2
- package/dist/mcp/webhooks/ingress.js +10 -0
- package/dist/native-worker/gateway-server.d.ts +14 -0
- package/dist/native-worker/gateway-server.js +28 -7
- package/dist/native-worker/gateway.d.ts +2 -0
- package/dist/native-worker/gateway.js +5 -1
- package/dist/native-worker/http-server.d.ts +4 -0
- package/dist/native-worker/http-server.js +51 -0
- package/dist/native-worker/ledger.d.ts +6 -2
- package/dist/native-worker/ledger.js +5 -4
- package/dist/native-worker/protocol.d.ts +1 -0
- package/dist/native-worker/protocol.js +4 -1
- package/dist/providers/coding-proxy/capability-hash.d.ts +2 -0
- package/dist/providers/coding-proxy/capability-hash.js +5 -0
- package/dist/providers/coding-proxy/mcp-route.d.ts +34 -0
- package/dist/providers/coding-proxy/mcp-route.js +166 -0
- package/dist/providers/coding-proxy/memory-ledger.d.ts +2 -0
- package/dist/providers/coding-proxy/memory-ledger.js +15 -0
- package/dist/providers/coding-proxy/prisma-ledger.d.ts +1 -0
- package/dist/providers/coding-proxy/prisma-ledger.js +30 -2
- package/dist/providers/coding-proxy/proxy.d.ts +7 -1
- package/dist/providers/coding-proxy/proxy.js +134 -34
- package/dist/providers/coding-proxy/runtime.d.ts +6 -0
- package/dist/providers/coding-proxy/runtime.js +32 -1
- package/dist/providers/coding-proxy/server.d.ts +3 -0
- package/dist/providers/coding-proxy/server.js +10 -0
- package/dist/providers/coding-proxy/types.d.ts +13 -0
- package/dist/providers/executor/composition.js +5 -0
- package/dist/providers/executor/container.d.ts +33 -1
- package/dist/providers/executor/container.js +76 -7
- package/dist/providers/host-tools/invoke.d.ts +37 -0
- package/dist/providers/host-tools/invoke.js +117 -0
- package/dist/providers/host-tools/operations.d.ts +65 -0
- package/dist/providers/host-tools/operations.js +149 -0
- package/dist/providers/host-tools/registry.d.ts +79 -0
- package/dist/providers/host-tools/registry.js +362 -0
- package/dist/providers/host-tools/request-guard.d.ts +17 -0
- package/dist/providers/host-tools/request-guard.js +99 -0
- package/dist/providers/host-tools/self-test.d.ts +64 -0
- package/dist/providers/host-tools/self-test.js +142 -0
- package/dist/providers/jobs/docker-isolation.d.ts +2 -2
- package/dist/providers/jobs/docker-isolation.js +30 -8
- package/dist/providers/jobs/docker.d.ts +8 -0
- package/dist/providers/jobs/docker.js +33 -9
- package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
- package/dist/providers/jobs/kubernetes-isolation.js +15 -2
- package/dist/providers/jobs/kubernetes.d.ts +5 -0
- package/dist/providers/jobs/kubernetes.js +6 -1
- package/dist/providers/jobs/types.d.ts +6 -0
- package/dist/providers/review-host/types.d.ts +6 -4
- package/dist/providers/review-host/types.js +1 -1
- package/dist/providers/vcs/git.d.ts +6 -2
- package/dist/providers/vcs/git.js +15 -4
- package/dist/providers/vcs/github.d.ts +2 -71
- package/dist/providers/vcs/github.js +8 -11
- package/dist/providers/vcs/index.d.ts +1 -1
- package/dist/providers/vcs/index.js +1 -1
- package/dist/providers/vcs/remote.d.ts +4 -0
- package/dist/providers/vcs/remote.js +7 -1
- package/dist/providers/vcs/routing.d.ts +12 -6
- package/dist/providers/vcs/routing.js +21 -6
- package/dist/providers/vcs/types.d.ts +71 -3
- package/dist/quickstart/index.d.ts +5 -0
- package/dist/quickstart/index.js +21 -0
- package/dist/quickstart-images.json +1 -1
- package/dist/sandbox/eval-core.d.ts +12 -2
- package/dist/sandbox/eval-core.js +20 -4
- package/dist/sandbox/host-functions.d.ts +4 -0
- package/dist/sandbox/host-functions.js +7 -2
- package/dist/sandbox/limits.d.ts +4 -1
- package/dist/sandbox/limits.js +4 -1
- package/dist/sandbox/run-in-sandbox.d.ts +6 -0
- package/dist/sandbox/run-in-sandbox.js +14 -2
- package/dist/sandbox/safe-fetch.d.ts +2 -0
- package/dist/sandbox/safe-fetch.js +1 -1
- package/dist/sandbox/secret-broker.d.ts +1 -0
- package/dist/sandbox/secret-broker.js +1 -1
- package/dist/sandbox/tool-timeout.d.ts +32 -0
- package/dist/sandbox/tool-timeout.js +52 -0
- package/dist/sandbox/user-tool.d.ts +8 -1
- package/dist/sandbox/user-tool.js +9 -3
- package/dist/viewer/graph.js +2 -1
- package/dist/wardby-bin.js +5 -0
- package/docs/README.md +11 -1
- package/docs/agent-recipes.md +37 -4
- package/docs/architecture-runtime.md +1 -1
- package/docs/claude-code-plugin.md +164 -0
- package/docs/code-review-agents.md +6 -0
- package/docs/coding-agent-setup.md +77 -1
- package/docs/coding-worker-byo-images.md +4 -2
- package/docs/coding-worker-isolation.md +78 -7
- package/docs/debugging-agent-runs.md +242 -0
- package/docs/getting-started-gke.md +5 -4
- package/docs/getting-started-identity-provider.md +43 -37
- package/docs/getting-started.md +8 -0
- package/docs/host-providers.md +294 -0
- package/docs/mcp-servers.md +254 -0
- package/docs/native-sandbox.md +25 -9
- package/docs/security-deployment.md +110 -31
- package/docs/tool-secrets.md +3 -0
- package/docs/viewer-api.md +6 -0
- package/docs/viewer-api.openapi.yml +1 -1
- package/help/admin-viewer.md +4 -0
- package/help/agent-recipes.md +33 -2
- package/help/brokered-secrets.md +3 -0
- package/help/build-claude-code-image.md +1 -1
- package/help/claude-code-run-pane.md +40 -0
- package/help/errors/coding-mcp-pins-unavailable.md +35 -0
- package/help/errors/database-auth-invalid.md +1 -1
- package/help/errors/host-provider-error.md +40 -0
- package/help/errors/host-tool-failed.md +36 -0
- package/help/errors/mcp-call-limit.md +22 -0
- package/help/errors/mcp-call-timeout.md +22 -0
- package/help/errors/mcp-server-auth-failed.md +22 -0
- package/help/errors/mcp-server-credentials-unavailable.md +45 -0
- package/help/errors/mcp-server-error.md +28 -0
- package/help/errors/mcp-server-unreachable.md +22 -0
- package/help/errors/mcp-servers-off.md +21 -0
- package/help/errors/mcp-tool-not-allowed.md +26 -0
- package/help/errors/mcp-tool-removed.md +22 -0
- package/help/errors/mcp-unavailable.md +25 -0
- package/help/errors/native-sandbox-capacity.md +1 -1
- package/help/errors/native-sandbox-deadline-exceeded.md +1 -1
- package/help/errors/native-sandbox-docker-failed.md +1 -1
- package/help/errors/native-sandbox-gateway-error.md +1 -1
- package/help/errors/native-sandbox-gateway-unavailable.md +1 -1
- package/help/errors/native-sandbox-image-not-pinned.md +1 -1
- package/help/errors/native-sandbox-isolation-mismatch.md +1 -1
- package/help/errors/native-sandbox-network-unenforced.md +1 -1
- package/help/errors/native-sandbox-requires-catalog.md +1 -1
- package/help/errors/native-sandbox-unavailable.md +1 -1
- package/help/errors/native-sandbox-warm-delivery-failed.md +1 -1
- package/help/errors/native-sandbox-worker-exited.md +1 -1
- package/help/errors/native-sandbox-worker-lost.md +1 -1
- package/help/errors/native-sandbox-worker-unready.md +1 -1
- package/help/errors/repository-not-listed.md +36 -0
- package/help/errors/repository-required.md +36 -0
- package/help/errors/tool-timeout.md +38 -0
- package/help/host-providers.md +40 -0
- package/help/identity-and-access.md +4 -1
- package/help/mcp-servers.md +73 -0
- package/help/mcp.md +7 -2
- package/help/native-capabilities.md +27 -1
- package/help/native-sandbox.md +1 -1
- package/help/operating-agents.md +16 -0
- package/help/related-pull-requests.md +7 -3
- package/help/repo-instructions-and-skills.md +3 -1
- package/help/resume-runs-locally.md +3 -0
- package/help/stop-run.md +1 -1
- package/help/troubleshooting/coding-workers.md +9 -1
- package/help/troubleshooting/debugging-agent-runs.md +29 -0
- package/package.json +2 -1
- package/prisma/migrations/20261102000000_host_providers/migration.sql +56 -0
- package/prisma/migrations/20261104000000_coding_agent_repository_list/migration.sql +5 -0
- package/prisma/migrations/20261106000000_tool_timeout/migration.sql +4 -0
- package/prisma/migrations/20261107000000_mcp_servers/migration.sql +33 -0
- package/prisma/migrations/20261108000000_coding_mcp/migration.sql +5 -0
- package/prisma/migrations/20261109000000_task_run_index/migration.sql +3 -0
- package/prisma/schema.prisma +159 -14
- package/scripts/git-askpass.sh +1 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP servers over Wardby's own MCP (docs/mcp-servers.md): register a server (HTTPS URL plus the
|
|
3
|
+
* owner's brokered secrets), keep its saved tool list fresh, and attach an allowlisted subset of its tools
|
|
4
|
+
* to an agent. Gated by WARDBY_MCP_SERVERS (off | admins | owners). Results carry secret names, never
|
|
5
|
+
* values: snapshots and errors arrive from fetchSnapshot already redacted. Audit lines carry the URL's host
|
|
6
|
+
* only, never the full URL (a query string can carry a token).
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { Prisma } from "#prisma";
|
|
10
|
+
import { loadMcpServersMode, MCP_SERVERS_OFF_MESSAGE } from "../../config/mcp-servers.js";
|
|
11
|
+
import { loadToolMaxTimeoutMs } from "../../config/tool-timeout.js";
|
|
12
|
+
import { atLeast, deleteGrantsFor, effectiveAccess, grantedIds } from "../../core/grants.js";
|
|
13
|
+
import { logger } from "../../core/logger.js";
|
|
14
|
+
import { McpClientError } from "../../core/mcp-client/client.js";
|
|
15
|
+
import { assertServerName, diffSnapshots, exposedNames, exposedToolName, fetchSnapshot, logCredentialsUnavailable, McpCredentialsUnavailableError, McpServerInputError, normaliseServerUrl, snapshotOf, } from "../../core/mcp-servers.js";
|
|
16
|
+
import { isCodexCoding, isMarkedReadOnly } from "../../core/mcp-tools.js";
|
|
17
|
+
import { REVIEW_HOST_TOOL_NAMES } from "../../core/review-host-tools.js";
|
|
18
|
+
import { preferIndexScans, withSerializableRetry } from "../../core/serializable-retry.js";
|
|
19
|
+
import { reservedToolNameReason } from "../../core/tool-names.js";
|
|
20
|
+
import { agentAccessResolver, assertAgentAccess, requireAgentAccess, requireBindable } from "../auth/access.js";
|
|
21
|
+
import { requireScope } from "../auth/resource-server.js";
|
|
22
|
+
import { McpError } from "../errors.js";
|
|
23
|
+
import { textResult } from "./text-result.js";
|
|
24
|
+
const serversLog = logger.child({ module: "mcp-servers" });
|
|
25
|
+
export { MCP_SERVERS_OFF_MESSAGE };
|
|
26
|
+
const DESCRIPTION_PREVIEW_CHARS = 300;
|
|
27
|
+
const MIN_CALL_TIMEOUT_MS = 1000;
|
|
28
|
+
/**
|
|
29
|
+
* Refuses when the mode is off, except for cleanup (delete and detach stay possible so an operator who
|
|
30
|
+
* turns the feature off can still remove servers and attachments); in admins mode, register/update/refresh
|
|
31
|
+
* also need hosts:admin.
|
|
32
|
+
*/
|
|
33
|
+
function gate(ctx, kind) {
|
|
34
|
+
const mode = loadMcpServersMode();
|
|
35
|
+
if (mode === "off" && kind !== "cleanup")
|
|
36
|
+
throw new McpError(403, MCP_SERVERS_OFF_MESSAGE);
|
|
37
|
+
if (kind === "configure" && mode === "admins")
|
|
38
|
+
requireScope(ctx, ctx.canonicalUri, "hosts:admin");
|
|
39
|
+
}
|
|
40
|
+
function audit(ctx, action, server) {
|
|
41
|
+
serversLog.info({
|
|
42
|
+
event: `mcp.server.${action}`,
|
|
43
|
+
serverId: server.id,
|
|
44
|
+
name: server.name,
|
|
45
|
+
host: new URL(server.url).hostname,
|
|
46
|
+
by: ctx.principal.id,
|
|
47
|
+
}, "MCP server changed");
|
|
48
|
+
}
|
|
49
|
+
function parse(schema, label, value) {
|
|
50
|
+
const result = schema.safeParse(value);
|
|
51
|
+
if (result.success)
|
|
52
|
+
return result.data;
|
|
53
|
+
const details = result.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
54
|
+
throw new McpError(400, `Invalid ${label}: ${details}`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Input errors → 400; MCP client errors (already redacted) → 502 with their code. Undecryptable
|
|
58
|
+
* credentials → 500 with their code: the fault is this Wardby server's key setup, not the remote
|
|
59
|
+
* server (502 would point at it) or the caller's input (400).
|
|
60
|
+
*/
|
|
61
|
+
async function mapped(work) {
|
|
62
|
+
try {
|
|
63
|
+
return await work();
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof McpServerInputError)
|
|
67
|
+
throw new McpError(400, err.message);
|
|
68
|
+
if (err instanceof McpCredentialsUnavailableError) {
|
|
69
|
+
logCredentialsUnavailable(serversLog, err);
|
|
70
|
+
throw new McpError(500, `${err.code}: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
if (err instanceof McpClientError)
|
|
73
|
+
throw new McpError(502, `${err.code}: ${err.message}`);
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function serverNotFound(id) {
|
|
78
|
+
return new McpError(404, `MCP server "${id}" not found.`);
|
|
79
|
+
}
|
|
80
|
+
/** The caller's own server, or 404 (whether it is missing or someone else's). */
|
|
81
|
+
async function requireOwnedServer(ctx, id, db = ctx.db) {
|
|
82
|
+
const server = await db.mcpServer.findUnique({ where: { id } });
|
|
83
|
+
if (!server || server.ownerId !== ctx.principal.id)
|
|
84
|
+
throw serverNotFound(id);
|
|
85
|
+
return server;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A server the caller owns or holds at least `min` on through a grant. Missing and no-access look the same
|
|
89
|
+
* (404); a grant that is too low says what it lacks (403).
|
|
90
|
+
*/
|
|
91
|
+
async function requireServerAccess(ctx, id, min, db = ctx.db) {
|
|
92
|
+
const server = await db.mcpServer.findUnique({ where: { id } });
|
|
93
|
+
if (!server)
|
|
94
|
+
throw serverNotFound(id);
|
|
95
|
+
const access = await effectiveAccess(db, "mcp_server", server, ctx.principal.id);
|
|
96
|
+
if (access === "none")
|
|
97
|
+
throw serverNotFound(id);
|
|
98
|
+
if (!atLeast("mcp_server", access, min)) {
|
|
99
|
+
throw new McpError(403, `MCP server "${server.name}": attaching it needs use access (you have ${access}).`);
|
|
100
|
+
}
|
|
101
|
+
return server;
|
|
102
|
+
}
|
|
103
|
+
function takeSnapshot(ctx, server) {
|
|
104
|
+
return mapped(() => fetchSnapshot(server, { db: ctx.db, cipher: ctx.providers.secrets, fetchImpl: ctx.providers.sandboxFetch }));
|
|
105
|
+
}
|
|
106
|
+
function snapshotData(snapshot) {
|
|
107
|
+
return {
|
|
108
|
+
toolSnapshot: snapshot.tools,
|
|
109
|
+
serverInfo: snapshot.serverInfo,
|
|
110
|
+
snapshotAt: new Date(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function serverView(server) {
|
|
114
|
+
return {
|
|
115
|
+
id: server.id,
|
|
116
|
+
name: server.name,
|
|
117
|
+
url: server.url,
|
|
118
|
+
secretNames: server.secretNames,
|
|
119
|
+
serverInfo: server.serverInfo,
|
|
120
|
+
snapshotAt: server.snapshotAt,
|
|
121
|
+
tools: snapshotOf(server).map((t) => ({
|
|
122
|
+
name: t.name,
|
|
123
|
+
description: t.description.slice(0, DESCRIPTION_PREVIEW_CHARS),
|
|
124
|
+
annotations: t.annotations,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** get_agent's mcpServers field: each attachment with its exposed names and allowed tools gone from the snapshot. */
|
|
129
|
+
export async function agentMcpServerViews(db, agentId) {
|
|
130
|
+
const rows = await db.agentMcpServer.findMany({
|
|
131
|
+
where: { agentId },
|
|
132
|
+
include: { server: { select: { id: true, name: true, toolSnapshot: true } } },
|
|
133
|
+
orderBy: { createdAt: "asc" },
|
|
134
|
+
});
|
|
135
|
+
return rows.map((row) => {
|
|
136
|
+
const present = new Set(snapshotOf(row.server).map((t) => t.name));
|
|
137
|
+
return {
|
|
138
|
+
serverId: row.server.id,
|
|
139
|
+
serverName: row.server.name,
|
|
140
|
+
allowedTools: row.allowedTools,
|
|
141
|
+
exposedNames: exposedNames(row.server.name, row.allowedTools),
|
|
142
|
+
callTimeoutMs: row.callTimeoutMs,
|
|
143
|
+
missingTools: row.allowedTools.filter((name) => !present.has(name)),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** The update_agent warning when an agent becomes Codex with attached tools the server doesn't mark read-only. */
|
|
148
|
+
export function codexWriteToolsWarning(names) {
|
|
149
|
+
return `These MCP tools aren't read-only and won't be available to Codex runs: ${names.join(", ")}.`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The exposed names of an agent's attached, allowed tools that the saved tool list doesn't mark read-only:
|
|
153
|
+
* a Codex run never gets them (loadMcpToolPins with readOnlyOnly). Tools no longer listed are left out.
|
|
154
|
+
*/
|
|
155
|
+
export async function codexUnavailableMcpTools(db, agentId) {
|
|
156
|
+
const rows = await db.agentMcpServer.findMany({
|
|
157
|
+
where: { agentId },
|
|
158
|
+
include: { server: { select: { name: true, toolSnapshot: true } } },
|
|
159
|
+
orderBy: [{ createdAt: "asc" }, { serverId: "asc" }],
|
|
160
|
+
});
|
|
161
|
+
return rows.flatMap((row) => {
|
|
162
|
+
const byName = new Map(snapshotOf(row.server).map((t) => [t.name, t]));
|
|
163
|
+
const writers = row.allowedTools.filter((name) => byName.has(name) && !isMarkedReadOnly(byName.get(name)));
|
|
164
|
+
return exposedNames(row.server.name, writers);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const SecretNames = z.array(z.string().min(1).max(1024)).max(8);
|
|
168
|
+
const RegisterSchema = z
|
|
169
|
+
.object({ name: z.string().max(64), url: z.string().max(2048), secretNames: SecretNames.default([]) })
|
|
170
|
+
.strict();
|
|
171
|
+
const UpdateSchema = z
|
|
172
|
+
.object({ id: z.string().min(1).max(64), url: z.string().max(2048).optional(), secretNames: SecretNames.optional() })
|
|
173
|
+
.strict();
|
|
174
|
+
const IdSchema = z.object({ id: z.string().min(1).max(64) }).strict();
|
|
175
|
+
const AttachSchema = z
|
|
176
|
+
.object({
|
|
177
|
+
agentId: z.string().min(1).max(64),
|
|
178
|
+
serverId: z.string().min(1).max(64),
|
|
179
|
+
allowedTools: z.array(z.string().min(1).max(200)).min(1).max(500),
|
|
180
|
+
callTimeoutMs: z.number().int().optional(),
|
|
181
|
+
})
|
|
182
|
+
.strict();
|
|
183
|
+
const DetachSchema = z.object({ agentId: z.string().min(1).max(64), serverId: z.string().min(1).max(64) }).strict();
|
|
184
|
+
const ID_ONLY = {
|
|
185
|
+
type: "object",
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
required: ["id"],
|
|
188
|
+
properties: { id: { type: "string" } },
|
|
189
|
+
};
|
|
190
|
+
export function registerMcpServerTools(mcp) {
|
|
191
|
+
mcp.registerTool({
|
|
192
|
+
name: "register_mcp_server",
|
|
193
|
+
scope: "tools:write",
|
|
194
|
+
description: "Register a remote MCP server (Streamable HTTP, https only) so its tools can be attached to your agents. secretNames are your brokered secrets, each brokered to the URL's host; they are sent on every request and never readable. Wardby calls the server's tools/list and saves the result. With WARDBY_MCP_SERVERS=admins this needs hosts:admin.",
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: "object",
|
|
197
|
+
additionalProperties: false,
|
|
198
|
+
required: ["name", "url"],
|
|
199
|
+
properties: {
|
|
200
|
+
name: {
|
|
201
|
+
type: "string",
|
|
202
|
+
description: "Lower-case letters, digits, _ or -; prefixes the tool names agents see.",
|
|
203
|
+
},
|
|
204
|
+
url: { type: "string", description: "The server's https endpoint, e.g. https://mcp.example.com/mcp." },
|
|
205
|
+
secretNames: { type: "array", items: { type: "string" } },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
handler: async (rawArgs, ctx) => {
|
|
209
|
+
gate(ctx, "configure");
|
|
210
|
+
const input = parse(RegisterSchema, "register_mcp_server arguments", rawArgs);
|
|
211
|
+
const url = await mapped(async () => {
|
|
212
|
+
assertServerName(input.name);
|
|
213
|
+
return normaliseServerUrl(input.url);
|
|
214
|
+
});
|
|
215
|
+
const ownerId = ctx.principal.id;
|
|
216
|
+
const taken = () => new McpError(409, `You already have an MCP server named "${input.name}".`);
|
|
217
|
+
if (await ctx.db.mcpServer.findUnique({ where: { ownerId_name: { ownerId, name: input.name } } }))
|
|
218
|
+
throw taken();
|
|
219
|
+
const draft = { id: "", ownerId, name: input.name, url, secretNames: input.secretNames };
|
|
220
|
+
const snapshot = await takeSnapshot(ctx, draft);
|
|
221
|
+
let server;
|
|
222
|
+
try {
|
|
223
|
+
server = await ctx.db.mcpServer.create({
|
|
224
|
+
data: { ownerId, name: input.name, url, secretNames: input.secretNames, ...snapshotData(snapshot) },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002")
|
|
229
|
+
throw taken();
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
audit(ctx, "register", server);
|
|
233
|
+
return textResult(serverView(server));
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
mcp.registerTool({
|
|
237
|
+
name: "update_mcp_server",
|
|
238
|
+
scope: "tools:write",
|
|
239
|
+
description: "Owner only: change a server's URL or secretNames. Wardby re-checks the secrets, saves a fresh tool list straight away and returns what changed (diff). With WARDBY_MCP_SERVERS=admins this needs hosts:admin.",
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: "object",
|
|
242
|
+
additionalProperties: false,
|
|
243
|
+
required: ["id"],
|
|
244
|
+
properties: {
|
|
245
|
+
id: { type: "string" },
|
|
246
|
+
url: { type: "string" },
|
|
247
|
+
secretNames: { type: "array", items: { type: "string" } },
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
handler: async (rawArgs, ctx) => {
|
|
251
|
+
gate(ctx, "configure");
|
|
252
|
+
const input = parse(UpdateSchema, "update_mcp_server arguments", rawArgs);
|
|
253
|
+
const current = await requireOwnedServer(ctx, input.id);
|
|
254
|
+
const url = input.url === undefined ? current.url : await mapped(async () => normaliseServerUrl(input.url));
|
|
255
|
+
const secretNames = input.secretNames ?? current.secretNames;
|
|
256
|
+
const snapshot = await takeSnapshot(ctx, { ...current, url, secretNames });
|
|
257
|
+
const server = await ctx.db.mcpServer.update({
|
|
258
|
+
where: { id: current.id },
|
|
259
|
+
data: { url, secretNames, ...snapshotData(snapshot) },
|
|
260
|
+
});
|
|
261
|
+
audit(ctx, "update", server);
|
|
262
|
+
return textResult({ ...serverView(server), diff: diffSnapshots(snapshotOf(current), snapshot.tools) });
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
mcp.registerTool({
|
|
266
|
+
name: "refresh_mcp_server",
|
|
267
|
+
scope: "tools:write",
|
|
268
|
+
description: "Owner only: fetch the server's tools/list again and save it. Changes apply straight away: an allowed tool the server no longer lists is left out of runs and shown under missingTools by get_agent. Returns the diff (added, removed, changed) and the agents left with missing tools. With WARDBY_MCP_SERVERS=admins this needs hosts:admin.",
|
|
269
|
+
inputSchema: ID_ONLY,
|
|
270
|
+
handler: async (rawArgs, ctx) => {
|
|
271
|
+
gate(ctx, "configure");
|
|
272
|
+
const { id } = parse(IdSchema, "refresh_mcp_server arguments", rawArgs);
|
|
273
|
+
const current = await requireOwnedServer(ctx, id);
|
|
274
|
+
const snapshot = await takeSnapshot(ctx, current);
|
|
275
|
+
const server = await ctx.db.mcpServer.update({ where: { id }, data: snapshotData(snapshot) });
|
|
276
|
+
audit(ctx, "refresh", server);
|
|
277
|
+
const present = new Set(snapshot.tools.map((t) => t.name));
|
|
278
|
+
const attachments = await ctx.db.agentMcpServer.findMany({
|
|
279
|
+
where: { serverId: id },
|
|
280
|
+
include: { agent: { select: { id: true, ownerId: true } } },
|
|
281
|
+
orderBy: { createdAt: "asc" },
|
|
282
|
+
});
|
|
283
|
+
// Name only agents the caller can read; count the rest, so a refresh never reveals others' agent ids.
|
|
284
|
+
const accessOf = await agentAccessResolver(ctx);
|
|
285
|
+
const affected = attachments
|
|
286
|
+
.map((a) => ({ agent: a.agent, missingTools: a.allowedTools.filter((t) => !present.has(t)) }))
|
|
287
|
+
.filter((a) => a.missingTools.length > 0);
|
|
288
|
+
const readable = affected.filter((a) => accessOf(a.agent) !== "none");
|
|
289
|
+
const hidden = affected.length - readable.length;
|
|
290
|
+
return textResult({
|
|
291
|
+
diff: diffSnapshots(snapshotOf(current), snapshot.tools),
|
|
292
|
+
affectedAgents: readable.map((a) => ({ agentId: a.agent.id, missingTools: a.missingTools })),
|
|
293
|
+
...(hidden > 0 ? { otherAffectedAgents: hidden } : {}),
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
mcp.registerTool({
|
|
298
|
+
name: "list_mcp_servers",
|
|
299
|
+
scope: "agents:read",
|
|
300
|
+
description: "Your registered MCP servers and the ones shared with you: id, name, host, tool count, when the tool list was saved and yours (false for shared ones, which show no secret names).",
|
|
301
|
+
inputSchema: { type: "object", additionalProperties: false, properties: {} },
|
|
302
|
+
handler: async (_rawArgs, ctx) => {
|
|
303
|
+
gate(ctx, "other");
|
|
304
|
+
const sharedIds = await grantedIds(ctx.db, "mcp_server", ctx.principal.id, "read");
|
|
305
|
+
const rows = await ctx.db.mcpServer.findMany({
|
|
306
|
+
where: { OR: [{ ownerId: ctx.principal.id }, { id: { in: sharedIds } }] },
|
|
307
|
+
orderBy: [{ name: "asc" }, { id: "asc" }],
|
|
308
|
+
});
|
|
309
|
+
return textResult({
|
|
310
|
+
servers: rows.map((s) => ({
|
|
311
|
+
id: s.id,
|
|
312
|
+
name: s.name,
|
|
313
|
+
host: new URL(s.url).hostname,
|
|
314
|
+
ownerId: s.ownerId,
|
|
315
|
+
toolCount: snapshotOf(s).length,
|
|
316
|
+
snapshotAt: s.snapshotAt,
|
|
317
|
+
yours: s.ownerId === ctx.principal.id,
|
|
318
|
+
// Secret names stay with the owner.
|
|
319
|
+
...(s.ownerId === ctx.principal.id ? { secretNames: s.secretNames } : {}),
|
|
320
|
+
})),
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
mcp.registerTool({
|
|
325
|
+
name: "get_mcp_server",
|
|
326
|
+
scope: "agents:read",
|
|
327
|
+
description: "One of your MCP servers, or one shared with you (read or higher), with its full saved tool list (name, description, inputSchema, annotations). Secret names show for the owner only.",
|
|
328
|
+
inputSchema: ID_ONLY,
|
|
329
|
+
handler: async (rawArgs, ctx) => {
|
|
330
|
+
gate(ctx, "other");
|
|
331
|
+
const { id } = parse(IdSchema, "get_mcp_server arguments", rawArgs);
|
|
332
|
+
const server = await requireServerAccess(ctx, id, "read");
|
|
333
|
+
const view = serverView(server);
|
|
334
|
+
const yours = server.ownerId === ctx.principal.id;
|
|
335
|
+
// Secret names and the full URL (a query string can carry a token) stay with the owner.
|
|
336
|
+
const { secretNames: _secretNames, url: _url, ...shared } = view;
|
|
337
|
+
void _secretNames;
|
|
338
|
+
void _url;
|
|
339
|
+
return textResult({
|
|
340
|
+
...(yours ? view : { ...shared, host: new URL(server.url).hostname }),
|
|
341
|
+
yours,
|
|
342
|
+
ownerId: server.ownerId,
|
|
343
|
+
tools: snapshotOf(server),
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
mcp.registerTool({
|
|
348
|
+
name: "delete_mcp_server",
|
|
349
|
+
scope: "tools:write",
|
|
350
|
+
description: "Owner only: delete an MCP server. It is detached from every agent, including agents others attached it to through a grant. Allowed even when WARDBY_MCP_SERVERS=off, so cleanup stays possible.",
|
|
351
|
+
inputSchema: ID_ONLY,
|
|
352
|
+
handler: async (rawArgs, ctx) => {
|
|
353
|
+
gate(ctx, "cleanup");
|
|
354
|
+
const { id } = parse(IdSchema, "delete_mcp_server arguments", rawArgs);
|
|
355
|
+
const server = await requireOwnedServer(ctx, id);
|
|
356
|
+
await ctx.db.$transaction(async (tx) => {
|
|
357
|
+
await tx.mcpServer.delete({ where: { id } });
|
|
358
|
+
// Grants have no foreign key to the server.
|
|
359
|
+
await deleteGrantsFor(tx, "mcp_server", id);
|
|
360
|
+
});
|
|
361
|
+
audit(ctx, "delete", server);
|
|
362
|
+
return textResult({ deleted: id });
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
mcp.registerTool({
|
|
366
|
+
name: "attach_mcp_server",
|
|
367
|
+
scope: "tools:write",
|
|
368
|
+
description: "Agent owner only: let an agent (native or coding) call the named tools of one of your MCP servers, or of one shared with you at use or higher (its tools then run with the owner's credentials). allowedTools are exact names from the saved tool list (no wildcards); the agent sees each as <server name>__<tool name>. callTimeoutMs (per call, default 30000) is capped by WARDBY_TOOL_MAX_TIMEOUT_MS. Re-attaching replaces the allowlist and time limit. Coding agents get the tools as mcp__wardby_mcp__<name>; Codex agents can use only tools the server marks read-only (readOnlyHint) in the saved tool list.",
|
|
369
|
+
inputSchema: {
|
|
370
|
+
type: "object",
|
|
371
|
+
additionalProperties: false,
|
|
372
|
+
required: ["agentId", "serverId", "allowedTools"],
|
|
373
|
+
properties: {
|
|
374
|
+
agentId: { type: "string" },
|
|
375
|
+
serverId: { type: "string" },
|
|
376
|
+
allowedTools: { type: "array", items: { type: "string" } },
|
|
377
|
+
callTimeoutMs: { type: "integer" },
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
handler: async (rawArgs, ctx) => {
|
|
381
|
+
gate(ctx, "other");
|
|
382
|
+
const input = parse(AttachSchema, "attach_mcp_server arguments", rawArgs);
|
|
383
|
+
const ceiling = loadToolMaxTimeoutMs();
|
|
384
|
+
if (input.callTimeoutMs !== undefined &&
|
|
385
|
+
(input.callTimeoutMs < MIN_CALL_TIMEOUT_MS || input.callTimeoutMs > ceiling)) {
|
|
386
|
+
throw new McpError(400, `callTimeoutMs must be at least ${MIN_CALL_TIMEOUT_MS} and at most ${ceiling} (WARDBY_TOOL_MAX_TIMEOUT_MS).`);
|
|
387
|
+
}
|
|
388
|
+
const duplicates = input.allowedTools.filter((t, i) => input.allowedTools.indexOf(t) !== i);
|
|
389
|
+
if (duplicates.length > 0)
|
|
390
|
+
throw new McpError(400, `allowedTools lists "${duplicates[0]}" more than once.`);
|
|
391
|
+
// Bounded retry: a concurrent grant change or make_owner on the same rows aborts one side.
|
|
392
|
+
await withSerializableRetry(() => ctx.db.$transaction(async (tx) => {
|
|
393
|
+
await preferIndexScans(tx);
|
|
394
|
+
const { agent } = await assertAgentAccess(ctx, await tx.agent.findUnique({ where: { id: input.agentId } }), input.agentId, "read", tx);
|
|
395
|
+
// An attachment grants capabilities (the server's secrets act for the agent): strict owner only.
|
|
396
|
+
await requireBindable(ctx, agent, tx);
|
|
397
|
+
const server = await requireServerAccess(ctx, input.serverId, "use", tx);
|
|
398
|
+
const known = new Set(snapshotOf(server).map((t) => t.name));
|
|
399
|
+
const unknown = input.allowedTools.filter((t) => !known.has(t));
|
|
400
|
+
if (unknown.length > 0) {
|
|
401
|
+
throw new McpError(400, `Not in MCP server "${server.name}"'s saved tool list: ${unknown.join(", ")}. Run refresh_mcp_server if the server added them.`);
|
|
402
|
+
}
|
|
403
|
+
if (agent.kind === "coding") {
|
|
404
|
+
const profile = await tx.codingAgentProfile.findUnique({
|
|
405
|
+
where: { agentId: agent.id },
|
|
406
|
+
select: { provider: true },
|
|
407
|
+
});
|
|
408
|
+
// Fails closed: a coding agent with no profile, or any provider but claude-code, is Codex here.
|
|
409
|
+
if (isCodexCoding(profile?.provider)) {
|
|
410
|
+
const byName = new Map(snapshotOf(server).map((t) => [t.name, t]));
|
|
411
|
+
const writer = input.allowedTools.find((t) => !isMarkedReadOnly(byName.get(t)));
|
|
412
|
+
if (writer !== undefined) {
|
|
413
|
+
throw new McpError(400, `Codex agents can use only tools the server marks read-only (${writer} isn't).`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const exposed = await mapped(async () => input.allowedTools.map((t) => exposedToolName(server.name, t)));
|
|
418
|
+
await assertNoClash(tx, agent.id, server.id, exposed);
|
|
419
|
+
const fields = { allowedTools: input.allowedTools, callTimeoutMs: input.callTimeoutMs ?? null };
|
|
420
|
+
await tx.agentMcpServer.upsert({
|
|
421
|
+
where: { agentId_serverId: { agentId: agent.id, serverId: server.id } },
|
|
422
|
+
create: { agentId: agent.id, serverId: server.id, attachedById: ctx.principal.id, ...fields },
|
|
423
|
+
update: { attachedById: ctx.principal.id, ...fields },
|
|
424
|
+
});
|
|
425
|
+
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }));
|
|
426
|
+
return textResult({ attached: true });
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
mcp.registerTool({
|
|
430
|
+
name: "detach_mcp_server",
|
|
431
|
+
scope: "tools:write",
|
|
432
|
+
description: "Agent owner only: remove an MCP server's tools from an agent. Allowed even when WARDBY_MCP_SERVERS=off, so cleanup stays possible.",
|
|
433
|
+
inputSchema: {
|
|
434
|
+
type: "object",
|
|
435
|
+
additionalProperties: false,
|
|
436
|
+
required: ["agentId", "serverId"],
|
|
437
|
+
properties: { agentId: { type: "string" }, serverId: { type: "string" } },
|
|
438
|
+
},
|
|
439
|
+
handler: async (rawArgs, ctx) => {
|
|
440
|
+
gate(ctx, "cleanup");
|
|
441
|
+
const input = parse(DetachSchema, "detach_mcp_server arguments", rawArgs);
|
|
442
|
+
await requireAgentAccess(ctx, input.agentId, "owner");
|
|
443
|
+
const { count } = await ctx.db.agentMcpServer.deleteMany({
|
|
444
|
+
where: { agentId: input.agentId, serverId: input.serverId },
|
|
445
|
+
});
|
|
446
|
+
return textResult({ detached: count > 0 });
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 409 naming the first exposed name that clashes with a built-in, an attached tool or another MCP attachment.
|
|
452
|
+
* Exported for tests: no valid server and tool name maps to a built-in repository tool name.
|
|
453
|
+
*/
|
|
454
|
+
export async function assertNoClash(tx, agentId, serverId, exposed) {
|
|
455
|
+
const clash = (name, why) => new McpError(409, `The tool name "${name}" clashes with ${why} on agent "${agentId}".`);
|
|
456
|
+
const seen = new Set();
|
|
457
|
+
for (const name of exposed) {
|
|
458
|
+
if (seen.has(name))
|
|
459
|
+
throw clash(name, "another of the allowed tools (both map to this name)");
|
|
460
|
+
seen.add(name);
|
|
461
|
+
const reserved = reservedToolNameReason(name);
|
|
462
|
+
if (reserved)
|
|
463
|
+
throw new McpError(409, `The tool name "${name}" clashes with a built-in: ${reserved}`);
|
|
464
|
+
if (REVIEW_HOST_TOOL_NAMES.has(name))
|
|
465
|
+
throw clash(name, "a built-in repository tool");
|
|
466
|
+
}
|
|
467
|
+
const attached = await tx.agentTool.findMany({ where: { agentId }, select: { tool: { select: { name: true } } } });
|
|
468
|
+
for (const { tool } of attached)
|
|
469
|
+
if (seen.has(tool.name))
|
|
470
|
+
throw clash(tool.name, "an attached tool");
|
|
471
|
+
const others = await tx.agentMcpServer.findMany({
|
|
472
|
+
where: { agentId, serverId: { not: serverId } },
|
|
473
|
+
include: { server: { select: { name: true } } },
|
|
474
|
+
});
|
|
475
|
+
for (const other of others) {
|
|
476
|
+
for (const name of exposedNames(other.server.name, other.allowedTools)) {
|
|
477
|
+
if (seen.has(name))
|
|
478
|
+
throw clash(name, `a tool of MCP server "${other.server.name}"`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* See docs/private/2026-09-26-repo-access-authorization-spec-and-plan.md.
|
|
13
13
|
*/
|
|
14
14
|
import { Prisma } from "#prisma";
|
|
15
|
-
import { isLocalRepository, normalizeGitHubRepository, normalizeLocalRepository } from "../../coding/protocol.js";
|
|
16
|
-
import {
|
|
15
|
+
import { CodingBaseRefSchema, isLocalRepository, normalizeGitHubRepository, normalizeLocalRepository, } from "../../coding/protocol.js";
|
|
16
|
+
import { codeProviderOf, normalizeHostedRepository } from "../../coding/code-host.js";
|
|
17
|
+
import { assertAgentAccess, requireAgentAccess, requireBindable, requireBindingOwner, requireNotPublicWritable, } from "../auth/access.js";
|
|
17
18
|
import { authorizeRepositoryForSet, canonicalLocalRepository } from "../auth/repo-authorization.js";
|
|
18
19
|
import { requireScope } from "../auth/resource-server.js";
|
|
19
20
|
import { McpError } from "../errors.js";
|
|
@@ -27,6 +28,9 @@ function normalizeRepository(provider, repository) {
|
|
|
27
28
|
return normalizeLocalRepository(repository);
|
|
28
29
|
if (provider === "github")
|
|
29
30
|
return normalizeGitHubRepository(repository);
|
|
31
|
+
const normalized = normalizeHostedRepository(repository);
|
|
32
|
+
if (codeProviderOf(normalized) === provider)
|
|
33
|
+
return normalized;
|
|
30
34
|
}
|
|
31
35
|
catch {
|
|
32
36
|
// fall through
|
|
@@ -58,7 +62,7 @@ export function registerRepositoryTools(mcp) {
|
|
|
58
62
|
mcp.registerTool({
|
|
59
63
|
name: "link_repository",
|
|
60
64
|
scope: "agents:write",
|
|
61
|
-
description: "Links
|
|
65
|
+
description: "Links an agent you own to a repository on a code-review host, with its access, event triggers, and checkName. " +
|
|
62
66
|
"Triggers: pull_request (review each PR push), mention (answer @mentions), push (start on merges to the default branch), " +
|
|
63
67
|
"review_fix (automatic review fix rounds). The review_fix trigger makes this agent the one wardby starts, automatically " +
|
|
64
68
|
"and without a human comment, when wardby's own review check requests changes on a pull request a wardby coding run " +
|
|
@@ -68,9 +72,10 @@ export function registerRepositoryTools(mcp) {
|
|
|
68
72
|
"pull_request trigger, and is unique per repository. waitForCi (only with the pull_request trigger) holds this agent's " +
|
|
69
73
|
"review on a pushed head until that head's own CI finishes, instead of racing it; APPROVE is refused while CI on the " +
|
|
70
74
|
"head is failing or still running. Re-linking an already-linked repository replaces its access, triggers, checkName, " +
|
|
71
|
-
"reviewFixMaxRounds, waitForCi, and
|
|
75
|
+
"reviewFixMaxRounds, waitForCi, markReadyWhenPassing, and baseRef (an omitted field is cleared, not kept) and re-checks access " +
|
|
72
76
|
"— always send the full desired state. A local repository (local:/abs/path, inside the server's trusted roots) takes " +
|
|
73
|
-
"no triggers or checkName: it supports manual reviews only (trigger_agent with review)."
|
|
77
|
+
"no triggers or checkName: it supports manual reviews only (trigger_agent with review). " +
|
|
78
|
+
"On a coding agent, adds a repository its runs may work in, with write access and no triggers: a GitHub repository or a local one.",
|
|
74
79
|
inputSchema: {
|
|
75
80
|
type: "object",
|
|
76
81
|
additionalProperties: false,
|
|
@@ -99,6 +104,10 @@ export function registerRepositoryTools(mcp) {
|
|
|
99
104
|
"after every Wardby review check passed, CI is green and the wardby merge order check (if any) " +
|
|
100
105
|
"is success (default false).",
|
|
101
106
|
},
|
|
107
|
+
baseRef: {
|
|
108
|
+
type: "string",
|
|
109
|
+
description: "Coding agents only: the base branch for runs in this repository. Default: the coding profile's baseRef.",
|
|
110
|
+
},
|
|
102
111
|
adminOverride: {
|
|
103
112
|
type: "boolean",
|
|
104
113
|
description: "Admins only (agents:admin with the admin role): approve this link without checking GitHub access, recorded as an admin approval. Allowed on any agent that has an owner, not only the admin's own.",
|
|
@@ -113,18 +122,22 @@ export function registerRepositoryTools(mcp) {
|
|
|
113
122
|
let agent;
|
|
114
123
|
if (args.adminOverride === true) {
|
|
115
124
|
requireScope(ctx, ctx.canonicalUri, "agents:admin");
|
|
116
|
-
agent = await ctx.db.agent.findUnique({
|
|
125
|
+
agent = await ctx.db.agent.findUnique({
|
|
126
|
+
where: { id: args.agentId },
|
|
127
|
+
include: { codingProfile: { select: { repository: true } } },
|
|
128
|
+
});
|
|
117
129
|
if (!agent)
|
|
118
130
|
throw new McpError(404, `Agent "${args.agentId}" not found.`);
|
|
119
131
|
await requireNotPublicWritable(agent, ctx.db);
|
|
120
132
|
}
|
|
121
133
|
else {
|
|
122
|
-
|
|
134
|
+
const row = await ctx.db.agent.findUnique({
|
|
135
|
+
where: { id: args.agentId },
|
|
136
|
+
include: { codingProfile: { select: { repository: true } } },
|
|
137
|
+
});
|
|
138
|
+
agent = (await assertAgentAccess(ctx, row, args.agentId, "read")).agent;
|
|
123
139
|
await requireBindable(ctx, agent);
|
|
124
140
|
}
|
|
125
|
-
if (agent.kind !== "native") {
|
|
126
|
-
throw new McpError(400, "Repository links are for native agents only; coding agents use codingProfile.repository.");
|
|
127
|
-
}
|
|
128
141
|
const provider = args.provider ?? (isLocalRepository(args.repository) ? "local" : "github");
|
|
129
142
|
if ((provider === "local") !== isLocalRepository(args.repository)) {
|
|
130
143
|
throw new McpError(400, `invalid repository "${args.repository}" for provider ${provider}`);
|
|
@@ -133,6 +146,32 @@ export function registerRepositoryTools(mcp) {
|
|
|
133
146
|
? await canonicalLocalRepository(args.repository)
|
|
134
147
|
: normalizeRepository(provider, args.repository);
|
|
135
148
|
const triggers = [...new Set(args.triggers ?? [])];
|
|
149
|
+
if (agent.kind === "coding") {
|
|
150
|
+
if (args.access !== "write")
|
|
151
|
+
throw new McpError(400, "A coding agent's repository link needs write access.");
|
|
152
|
+
// As codingProfile.repository: anything else would pass here and fail every run at dispatch.
|
|
153
|
+
// What a coding run can work in, matching the coding profile.
|
|
154
|
+
if (provider !== "github" && provider !== "local") {
|
|
155
|
+
throw new McpError(400, "A coding agent's repository link must be a GitHub repository or a local: path.");
|
|
156
|
+
}
|
|
157
|
+
if (triggers.length > 0 ||
|
|
158
|
+
args.checkName !== undefined ||
|
|
159
|
+
args.reviewFixMaxRounds !== undefined ||
|
|
160
|
+
args.waitForCi !== undefined ||
|
|
161
|
+
args.markReadyWhenPassing !== undefined) {
|
|
162
|
+
throw new McpError(400, "A coding agent's repository link takes no triggers, checkName, reviewFixMaxRounds, waitForCi or markReadyWhenPassing.");
|
|
163
|
+
}
|
|
164
|
+
if (agent.codingProfile?.repository === repository) {
|
|
165
|
+
throw new McpError(400, `${repository} is already its default repository (codingProfile.repository).`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else if (args.baseRef !== undefined) {
|
|
169
|
+
throw new McpError(400, "baseRef applies only to a coding agent's repository link.");
|
|
170
|
+
}
|
|
171
|
+
const parsedBaseRef = args.baseRef === undefined ? null : CodingBaseRefSchema.safeParse(args.baseRef);
|
|
172
|
+
if (parsedBaseRef && !parsedBaseRef.success)
|
|
173
|
+
throw new McpError(400, "baseRef must be a safe branch ref.");
|
|
174
|
+
const baseRef = parsedBaseRef ? parsedBaseRef.data : null;
|
|
136
175
|
if (provider === "local") {
|
|
137
176
|
if (triggers.length > 0)
|
|
138
177
|
throw new McpError(400, "local repositories support manual review only");
|
|
@@ -185,6 +224,7 @@ export function registerRepositoryTools(mcp) {
|
|
|
185
224
|
reviewFixMaxRounds,
|
|
186
225
|
waitForCi,
|
|
187
226
|
markReadyWhenPassing,
|
|
227
|
+
baseRef,
|
|
188
228
|
...authorization,
|
|
189
229
|
};
|
|
190
230
|
return tx.agentRepository.upsert({
|