@wardby/cli 0.3.0 → 0.4.1
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 +22 -3
- package/README.md +11 -9
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +68 -10
- package/dist/coding/base-commit.d.ts +6 -0
- package/dist/coding/base-commit.js +12 -0
- package/dist/coding/protocol.d.ts +17 -1
- package/dist/coding/protocol.js +17 -6
- package/dist/coding/provider.d.ts +6 -1
- package/dist/coding/provider.js +16 -9
- package/dist/config/providers.d.ts +25 -0
- package/dist/config/providers.js +71 -0
- package/dist/core/attribution.d.ts +101 -0
- package/dist/core/attribution.js +208 -0
- package/dist/core/budget-groups.d.ts +17 -10
- package/dist/core/budget-groups.js +15 -12
- package/dist/core/coding-queue.d.ts +3 -0
- package/dist/core/coding-queue.js +6 -2
- package/dist/core/coding-service-status.d.ts +11 -0
- package/dist/core/coding-service-status.js +17 -0
- package/dist/core/cost-report.d.ts +88 -0
- package/dist/core/cost-report.js +248 -0
- package/dist/core/dispatch.d.ts +42 -2
- package/dist/core/dispatch.js +144 -26
- package/dist/core/engine-native.js +17 -4
- package/dist/core/glob.d.ts +10 -0
- package/dist/core/glob.js +33 -0
- package/dist/core/host-events.d.ts +24 -1
- package/dist/core/host-events.js +341 -1
- package/dist/core/host-status.d.ts +19 -2
- package/dist/core/host-status.js +47 -30
- package/dist/core/issue-bridge.d.ts +60 -0
- package/dist/core/issue-bridge.js +189 -0
- package/dist/core/issue-dedupe.d.ts +70 -0
- package/dist/core/issue-dedupe.js +255 -0
- package/dist/core/issue-events.d.ts +42 -0
- package/dist/core/issue-events.js +155 -0
- package/dist/core/issue-status.d.ts +29 -0
- package/dist/core/issue-status.js +241 -0
- package/dist/core/issue-tracker-tools.d.ts +64 -0
- package/dist/core/issue-tracker-tools.js +850 -0
- package/dist/core/model-usage.d.ts +10 -0
- package/dist/core/model-usage.js +24 -0
- package/dist/core/reconciler.d.ts +8 -4
- package/dist/core/reconciler.js +15 -4
- package/dist/core/review-host-tools.js +10 -3
- package/dist/core/run-pricing.d.ts +61 -0
- package/dist/core/run-pricing.js +56 -0
- package/dist/core/runner.d.ts +5 -2
- package/dist/core/runner.js +178 -27
- package/dist/core/scheduler.d.ts +4 -1
- package/dist/core/scheduler.js +3 -2
- package/dist/core/self-defects.d.ts +80 -0
- package/dist/core/self-defects.js +180 -0
- package/dist/core/tool-names.js +3 -0
- package/dist/core/webhooks.d.ts +9 -1
- package/dist/core/webhooks.js +19 -1
- package/dist/env.js +6 -1
- package/dist/generated/prisma/browser.d.ts +66 -0
- package/dist/generated/prisma/client.d.ts +66 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +122 -52
- package/dist/generated/prisma/enums.d.ts +7 -0
- package/dist/generated/prisma/enums.js +6 -0
- package/dist/generated/prisma/internal/class.d.ts +99 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +826 -1
- package/dist/generated/prisma/internal/prismaNamespace.js +135 -2
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +142 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -2
- package/dist/generated/prisma/models/Agent.d.ts +389 -1
- package/dist/generated/prisma/models/AgentIssueProject.d.ts +1838 -0
- package/dist/generated/prisma/models/AgentIssueProject.js +1 -0
- package/dist/generated/prisma/models/AgentRepository.d.ts +1 -1
- package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
- package/dist/generated/prisma/models/CodingProxySession.d.ts +73 -1
- package/dist/generated/prisma/models/CodingRun.d.ts +130 -1
- package/dist/generated/prisma/models/CodingRunServiceStatus.d.ts +1404 -0
- package/dist/generated/prisma/models/CodingRunServiceStatus.js +1 -0
- package/dist/generated/prisma/models/IssueFingerprint.d.ts +1183 -0
- package/dist/generated/prisma/models/IssueFingerprint.js +1 -0
- package/dist/generated/prisma/models/IssuePullRequest.d.ts +1255 -0
- package/dist/generated/prisma/models/IssuePullRequest.js +1 -0
- package/dist/generated/prisma/models/ModelCatalogEntry.d.ts +1322 -0
- package/dist/generated/prisma/models/ModelCatalogEntry.js +1 -0
- package/dist/generated/prisma/models/Run.d.ts +933 -1
- package/dist/generated/prisma/models/RunAttribution.d.ts +1259 -0
- package/dist/generated/prisma/models/RunAttribution.js +1 -0
- package/dist/generated/prisma/models/RunIssueStatus.d.ts +1199 -0
- package/dist/generated/prisma/models/RunIssueStatus.js +1 -0
- package/dist/generated/prisma/models/RunModelUsage.d.ts +1316 -0
- package/dist/generated/prisma/models/RunModelUsage.js +1 -0
- package/dist/generated/prisma/models/WorkItem.d.ts +1408 -0
- package/dist/generated/prisma/models/WorkItem.js +1 -0
- package/dist/generated/prisma/models.d.ts +9 -0
- package/dist/help-index.json +355 -16
- package/dist/import/neutral-schema.d.ts +16 -16
- package/dist/knowledge/check.d.ts +13 -0
- package/dist/knowledge/check.js +69 -0
- package/dist/knowledge/cli.d.ts +14 -0
- package/dist/knowledge/cli.js +67 -0
- package/dist/knowledge/concept.d.ts +54 -0
- package/dist/knowledge/concept.js +78 -0
- package/dist/knowledge/note.d.ts +11 -0
- package/dist/knowledge/note.js +39 -0
- package/dist/knowledge/relevance.d.ts +11 -0
- package/dist/knowledge/relevance.js +14 -0
- package/dist/knowledge/span-hash.d.ts +3 -0
- package/dist/knowledge/span-hash.js +16 -0
- package/dist/mcp/auth/access.d.ts +4 -2
- package/dist/mcp/auth/ownership.d.ts +9 -9
- package/dist/mcp/auth/resource-server.d.ts +3 -1
- package/dist/mcp/auth/resource-server.js +18 -3
- package/dist/mcp/auth/self-hosted/credentials.d.ts +3 -3
- package/dist/mcp/auth/self-hosted/session.d.ts +5 -5
- package/dist/mcp/context.d.ts +3 -0
- package/dist/mcp/host-events/deliveries.d.ts +9 -0
- package/dist/mcp/host-events/deliveries.js +17 -0
- package/dist/mcp/host-events/github-ingress.d.ts +4 -2
- package/dist/mcp/host-events/github-ingress.js +4 -13
- package/dist/mcp/host-events/jira-ingress.d.ts +29 -0
- package/dist/mcp/host-events/jira-ingress.js +92 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +87 -9
- package/dist/mcp/server.js +5 -2
- package/dist/mcp/tools/agents.js +74 -3
- package/dist/mcp/tools/cost-report.d.ts +8 -0
- package/dist/mcp/tools/cost-report.js +60 -0
- package/dist/mcp/tools/issue-projects.d.ts +2 -0
- package/dist/mcp/tools/issue-projects.js +238 -0
- package/dist/mcp/tools/model-catalog.d.ts +22 -0
- package/dist/mcp/tools/model-catalog.js +423 -0
- package/dist/mcp/tools/repositories.js +2 -1
- package/dist/mcp/tools/tools.d.ts +2 -2
- package/dist/mcp/tools/trigger.js +33 -5
- package/dist/mcp/transport/streamable-http.d.ts +5 -0
- package/dist/mcp/transport/streamable-http.js +23 -1
- package/dist/mcp/webhooks/ingress.d.ts +2 -1
- package/dist/mcp/webhooks/ingress.js +9 -2
- package/dist/providers/auth/self-hosted.d.ts +8 -1
- package/dist/providers/auth/self-hosted.js +39 -2
- package/dist/providers/coding-proxy/memory-ledger.d.ts +1 -1
- package/dist/providers/coding-proxy/memory-ledger.js +10 -1
- package/dist/providers/coding-proxy/metering.d.ts +2 -1
- package/dist/providers/coding-proxy/metering.js +13 -2
- package/dist/providers/coding-proxy/prisma-ledger.js +59 -6
- package/dist/providers/coding-proxy/proxy.d.ts +12 -2
- package/dist/providers/coding-proxy/proxy.js +92 -30
- package/dist/providers/coding-proxy/types.d.ts +18 -1
- package/dist/providers/coding-proxy/types.js +12 -1
- package/dist/providers/engine/types.d.ts +19 -0
- package/dist/providers/executor/composition.js +9 -1
- package/dist/providers/executor/container.d.ts +30 -2
- package/dist/providers/executor/container.js +98 -17
- package/dist/providers/executor/dbos.d.ts +2 -0
- package/dist/providers/executor/dbos.js +7 -5
- package/dist/providers/executor/routing.d.ts +6 -0
- package/dist/providers/executor/routing.js +5 -0
- package/dist/providers/executor/types.d.ts +12 -0
- package/dist/providers/issue-tracker/adf.d.ts +31 -0
- package/dist/providers/issue-tracker/adf.js +181 -0
- package/dist/providers/issue-tracker/index.d.ts +5 -0
- package/dist/providers/issue-tracker/index.js +12 -0
- package/dist/providers/issue-tracker/jira-client.d.ts +41 -0
- package/dist/providers/issue-tracker/jira-client.js +151 -0
- package/dist/providers/issue-tracker/jira-events.d.ts +3 -0
- package/dist/providers/issue-tracker/jira-events.js +98 -0
- package/dist/providers/issue-tracker/jira.d.ts +116 -0
- package/dist/providers/issue-tracker/jira.js +502 -0
- package/dist/providers/issue-tracker/types.d.ts +269 -0
- package/dist/providers/issue-tracker/types.js +16 -0
- package/dist/providers/jobs/docker.d.ts +5 -1
- package/dist/providers/jobs/docker.js +61 -33
- package/dist/providers/jobs/kubernetes.d.ts +3 -0
- package/dist/providers/jobs/kubernetes.js +44 -4
- package/dist/providers/jobs/service-state.d.ts +22 -0
- package/dist/providers/jobs/service-state.js +17 -0
- package/dist/providers/llm/anthropic.d.ts +3 -3
- package/dist/providers/llm/anthropic.js +3 -9
- package/dist/providers/llm/bedrock.d.ts +3 -3
- package/dist/providers/llm/bedrock.js +3 -9
- package/dist/providers/llm/catalog-lookup.d.ts +10 -0
- package/dist/providers/llm/catalog-lookup.js +15 -0
- package/dist/providers/llm/catalog-shipped.d.ts +18 -0
- package/dist/providers/llm/catalog-shipped.js +197 -0
- package/dist/providers/llm/catalog-store.d.ts +58 -0
- package/dist/providers/llm/catalog-store.js +138 -0
- package/dist/providers/llm/catalog-types.d.ts +66 -0
- package/dist/providers/llm/catalog-types.js +64 -0
- package/dist/providers/llm/catalog.d.ts +61 -0
- package/dist/providers/llm/catalog.js +147 -0
- package/dist/providers/llm/claude-provider.d.ts +10 -14
- package/dist/providers/llm/claude-provider.js +11 -6
- package/dist/providers/llm/index.d.ts +9 -6
- package/dist/providers/llm/index.js +8 -5
- package/dist/providers/llm/openai.d.ts +14 -5
- package/dist/providers/llm/openai.js +24 -14
- package/dist/providers/llm/pricing-core.d.ts +5 -3
- package/dist/providers/llm/registration.js +8 -12
- package/dist/providers/llm/routing.d.ts +18 -17
- package/dist/providers/llm/routing.js +40 -24
- package/dist/providers/review-host/github-events.js +47 -1
- package/dist/providers/review-host/github.js +7 -6
- package/dist/providers/review-host/types.d.ts +25 -0
- package/dist/providers/vcs/git.js +2 -22
- package/dist/providers/vcs/github.d.ts +20 -0
- package/dist/providers/vcs/github.js +28 -2
- package/dist/providers/vcs/types.d.ts +6 -0
- package/dist/quickstart/index.d.ts +8 -0
- package/dist/quickstart/index.js +34 -34
- package/dist/serve.js +8 -2
- package/dist/viewer/api-schema.d.ts +2757 -0
- package/dist/viewer/api-schema.js +165 -0
- package/dist/viewer/build-schemas.d.ts +2 -0
- package/dist/viewer/build-schemas.js +18 -0
- package/dist/viewer/event-bus.d.ts +38 -0
- package/dist/viewer/event-bus.js +232 -0
- package/dist/viewer/graph.d.ts +40 -0
- package/dist/viewer/graph.js +243 -0
- package/dist/viewer/http.d.ts +30 -0
- package/dist/viewer/http.js +133 -0
- package/dist/viewer/run-detail.d.ts +4 -0
- package/dist/viewer/run-detail.js +61 -0
- package/dist/wardby-bin.js +5 -0
- package/docs/README.md +10 -0
- package/docs/agent-recipes.md +383 -0
- package/docs/code-review-agents.md +29 -2
- package/docs/coding-agent-setup.md +3 -0
- package/docs/coding-worker-isolation.md +39 -5
- package/docs/getting-started-gke.md +28 -11
- package/docs/getting-started-identity-provider.md +49 -38
- package/docs/getting-started.md +14 -0
- package/docs/jira-agents.md +649 -0
- package/docs/knowledge.md +387 -0
- package/docs/models.md +221 -0
- package/docs/security-deployment.md +19 -9
- package/docs/viewer-api.md +142 -0
- package/help/admin-viewer.md +39 -0
- package/help/agent-recipes.md +173 -0
- package/help/architecture-agent.md +189 -0
- package/help/builder-agent.md +80 -0
- package/help/code-review-agents.md +6 -0
- package/help/cost-attribution.md +67 -0
- package/help/creating-agents.md +22 -0
- package/help/deploy-gke.md +6 -0
- package/help/errors/model-unavailable.md +63 -0
- package/help/getting-started.md +1 -0
- package/help/github.md +18 -0
- package/help/identity-and-access.md +8 -3
- package/help/jira.md +135 -0
- package/help/knowledge.md +47 -0
- package/help/models.md +90 -0
- package/help/operating-agents.md +7 -1
- package/help/troubleshooting/budgets.md +6 -0
- package/package.json +5 -2
- package/prisma/migrations/20260930000000_jira_issue_projects/migration.sql +34 -0
- package/prisma/migrations/20261001000000_jira_phase2_allowlists/migration.sql +3 -0
- package/prisma/migrations/20261001010000_jira_link_types_allowlist/migration.sql +2 -0
- package/prisma/migrations/20261002000000_jira_coding_bridge/migration.sql +28 -0
- package/prisma/migrations/20261002010000_jira_issue_creation/migration.sql +25 -0
- package/prisma/migrations/20261003000000_issue_cost_attribution/migration.sql +56 -0
- package/prisma/migrations/20261003010000_coding_run_service_status/migration.sql +23 -0
- package/prisma/migrations/20261003020000_viewer_notify/migration.sql +54 -0
- package/prisma/migrations/20261003030000_viewer_notify_fixes/migration.sql +47 -0
- package/prisma/migrations/20261003040000_viewer_indexes/migration.sql +12 -0
- package/prisma/migrations/20261004000000_model_catalog/migration.sql +26 -0
- package/prisma/schema.prisma +258 -2
- package/dist/mcp/tools/models.d.ts +0 -8
- package/dist/mcp/tools/models.js +0 -15
- package/dist/providers/llm/pricing-anthropic.d.ts +0 -14
- package/dist/providers/llm/pricing-anthropic.js +0 -48
- package/dist/providers/llm/pricing-bedrock-claude.d.ts +0 -20
- package/dist/providers/llm/pricing-bedrock-claude.js +0 -46
- package/dist/providers/llm/pricing.d.ts +0 -30
- package/dist/providers/llm/pricing.js +0 -74
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model catalog over MCP (docs/models.md). Reading it (list_models,
|
|
3
|
+
* get_model) needs only agents:read: anyone deciding what to put in an
|
|
4
|
+
* Agent.model needs to see what's routable, what it costs, and whether it's
|
|
5
|
+
* shipped or a deployment override — entries hold no secrets. Changing it
|
|
6
|
+
* (set_model, disable_model, reset_model) needs models:admin, a privileged
|
|
7
|
+
* scope honoured only with a role granting it (model-manager or admin;
|
|
8
|
+
* resource-server.ts): an entry decides what every principal's runs on that
|
|
9
|
+
* model id are priced and shaped as. Each change is audit-logged (event
|
|
10
|
+
* models.catalog.<action>). Runs already dispatched keep the entry they
|
|
11
|
+
* started with (CatalogEntry snapshots, not live lookups) — a write here
|
|
12
|
+
* never reprices or reshapes a run in flight.
|
|
13
|
+
*
|
|
14
|
+
* Correctness of a write never depends on the process's installed
|
|
15
|
+
* CatalogStore, which can be stale (it polls on an interval) or briefly
|
|
16
|
+
* unreachable:
|
|
17
|
+
* - `set_model`'s provider-conflict check reads `ctx.db` directly (plus the
|
|
18
|
+
* static shipped catalog, which is never stale), not the in-memory
|
|
19
|
+
* catalog — a conflicting row written by another process/replica and
|
|
20
|
+
* not yet polled in here must still be caught.
|
|
21
|
+
* - Every write's response and audit event are built from the data just
|
|
22
|
+
* written (what we know is true), never from a post-write re-read of the
|
|
23
|
+
* catalog — so a refresh failure can never turn a successful write into
|
|
24
|
+
* an unaudited one, or into an error.
|
|
25
|
+
* - `deps.refresh` is still called best-effort after each write (so this
|
|
26
|
+
* process's own routing picks up the change promptly); a failure is
|
|
27
|
+
* logged and swallowed, never thrown.
|
|
28
|
+
* - `set_model` additionally re-checks ownership after a *successful*
|
|
29
|
+
* refresh: two processes racing to claim the same model id under
|
|
30
|
+
* different providers can both write (the compound key is
|
|
31
|
+
* (provider, modelId), not modelId alone) — `buildCatalog` then silently
|
|
32
|
+
* picks one winner. If the merge didn't pick us, we report an error
|
|
33
|
+
* rather than success, even though our own row did get written.
|
|
34
|
+
*/
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { logger } from "../../core/logger.js";
|
|
37
|
+
import { McpError } from "../errors.js";
|
|
38
|
+
import { textResult } from "./text-result.js";
|
|
39
|
+
import { MODEL_PROVIDERS, THINKING_MODES, TOKENIZER_ENCODINGS, entryOf, sameEntry, } from "../../providers/llm/catalog-types.js";
|
|
40
|
+
import { LLM_EFFORT_LEVELS } from "../../providers/llm/types.js";
|
|
41
|
+
import { ownerOf, rowFromRecord } from "../../providers/llm/catalog.js";
|
|
42
|
+
import { currentModelCatalog } from "../../providers/llm/catalog-store.js";
|
|
43
|
+
import { SHIPPED_CATALOG_VERSION } from "../../providers/llm/catalog-shipped.js";
|
|
44
|
+
const modelsLog = logger.child({ module: "mcp-model-catalog" });
|
|
45
|
+
const RATE_FIELDS = ["inputPerMTok", "outputPerMTok", "cachedInputPerMTok", "cacheWritePerMTok"];
|
|
46
|
+
function parse(schema, label, value) {
|
|
47
|
+
const result = schema.safeParse(value);
|
|
48
|
+
if (!result.success) {
|
|
49
|
+
const details = result.error.issues
|
|
50
|
+
.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`)
|
|
51
|
+
.join("; ");
|
|
52
|
+
throw new McpError(400, `Invalid ${label}: ${details}`);
|
|
53
|
+
}
|
|
54
|
+
return result.data;
|
|
55
|
+
}
|
|
56
|
+
const ModelIdSchema = z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1)
|
|
59
|
+
.max(200)
|
|
60
|
+
.regex(/^[A-Za-z0-9._:/-]+$/, "modelId must match ^[A-Za-z0-9._:/-]+$");
|
|
61
|
+
const ModelIdArgSchema = z.object({ modelId: ModelIdSchema }).strict();
|
|
62
|
+
const ListModelsSchema = z.object({ includeDisabled: z.boolean().optional() }).strict();
|
|
63
|
+
const SetModelSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
provider: z.enum(MODEL_PROVIDERS),
|
|
66
|
+
modelId: ModelIdSchema,
|
|
67
|
+
encoding: z.enum(TOKENIZER_ENCODINGS),
|
|
68
|
+
inputPerMTok: z.number().finite().nonnegative(),
|
|
69
|
+
outputPerMTok: z.number().finite().nonnegative(),
|
|
70
|
+
cachedInputPerMTok: z.number().finite().nonnegative(),
|
|
71
|
+
cacheWritePerMTok: z.number().finite().nonnegative(),
|
|
72
|
+
efforts: z
|
|
73
|
+
.array(z.enum(LLM_EFFORT_LEVELS))
|
|
74
|
+
.refine((efforts) => new Set(efforts).size === efforts.length, "efforts must not repeat a level"),
|
|
75
|
+
thinkingMode: z.enum(THINKING_MODES),
|
|
76
|
+
sourceUrl: z
|
|
77
|
+
.string()
|
|
78
|
+
.url()
|
|
79
|
+
.refine((url) => url.startsWith("https://"), "sourceUrl must start with https://"),
|
|
80
|
+
})
|
|
81
|
+
.strict();
|
|
82
|
+
/** A resolved entry as the tools serve it: the merged fields plus whether this deployment can route to it right now. */
|
|
83
|
+
function view(entry, llm) {
|
|
84
|
+
return { ...entry, routable: llm.hasProvider(entry.provider) };
|
|
85
|
+
}
|
|
86
|
+
function audit(ctx, action, info) {
|
|
87
|
+
modelsLog.info({
|
|
88
|
+
event: `models.catalog.${action}`,
|
|
89
|
+
provider: info.provider,
|
|
90
|
+
...(info.providers && info.providers.length > 1 ? { providers: info.providers } : {}),
|
|
91
|
+
modelId: info.modelId,
|
|
92
|
+
sourceUrl: info.sourceUrl,
|
|
93
|
+
before: info.before,
|
|
94
|
+
after: info.after,
|
|
95
|
+
by: ctx.principal.id,
|
|
96
|
+
}, "model catalog changed");
|
|
97
|
+
}
|
|
98
|
+
/** Best-effort: logs and swallows a refresh failure rather than letting it fail (or un-audit) an already-committed write. Returns whether it actually ran and succeeded. */
|
|
99
|
+
async function refreshBestEffort(refresh, context) {
|
|
100
|
+
if (!refresh)
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
await refresh();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
modelsLog.warn({ event: "models.catalog.refresh_failed", ...context, err: err instanceof Error ? err.message : String(err) }, "model catalog refresh failed after a write; the write stands, but routing may lag until the next poll");
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `shipped` says whether `modelId` is a shipped model: if so, `provider` is
|
|
113
|
+
* necessarily its shipped provider (ownerOf), and no action — disabling
|
|
114
|
+
* or resetting the override, or anything else — ever lets a different
|
|
115
|
+
* provider claim it, so the message says that plainly rather than pointing
|
|
116
|
+
* at a remedy that doesn't exist. For a non-shipped id, `reset_model` is the
|
|
117
|
+
* only thing that frees it for another provider: `disable_model` keeps the
|
|
118
|
+
* row (and its provider claim) in place, it just flips `enabled`, so
|
|
119
|
+
* disabling never frees the id either, whether `enabled` is currently true
|
|
120
|
+
* or false.
|
|
121
|
+
*/
|
|
122
|
+
function conflictError(modelId, provider, enabled, shipped) {
|
|
123
|
+
if (shipped) {
|
|
124
|
+
return new McpError(409, `Model "${modelId}" is a shipped model: it always belongs to provider "${provider}" and can never be ` +
|
|
125
|
+
`served by another provider — disabling or resetting the override does not change that.`);
|
|
126
|
+
}
|
|
127
|
+
return enabled
|
|
128
|
+
? new McpError(409, `Model "${modelId}" is already served by provider "${provider}"; reset_model it first to free the id ` +
|
|
129
|
+
`for another provider (disable_model does not free it).`)
|
|
130
|
+
: new McpError(409, `Model "${modelId}" is reserved by a disabled entry for provider "${provider}"; reset_model it first.`);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The post-write ownership-race error: our own row for `inputProvider` was
|
|
134
|
+
* written, but a refresh right after showed `nowOwner` winning the merge
|
|
135
|
+
* instead (the first-claim-wins rule — see `ownerOf` in catalog.ts). Named
|
|
136
|
+
* after the thing that actually fixes it (reset_model, which clears every
|
|
137
|
+
* row for the id, `nowOwner`'s included) rather than telling the caller to
|
|
138
|
+
* "retry set_model", which would just hit the pre-write conflict check again
|
|
139
|
+
* (or, if the race repeats, loop). Exported so its exact wording can be unit
|
|
140
|
+
* tested directly — forcing the genuine two-process write race that triggers
|
|
141
|
+
* this through the public tool-call surface isn't practical: it requires a
|
|
142
|
+
* competing write to land in the narrow window between this handler's own
|
|
143
|
+
* pre-write ownership check and its own write, which a single-process test
|
|
144
|
+
* harness has no way to inject mid-call.
|
|
145
|
+
*/
|
|
146
|
+
export function raceLostError(modelId, inputProvider, nowOwner) {
|
|
147
|
+
return new McpError(409, `Model "${modelId}" was written for provider "${inputProvider}" but is not serving it: provider "${nowOwner}" ` +
|
|
148
|
+
`owns it in the merged catalog. reset_model removes every row for "${modelId}" (including "${nowOwner}"'s); ` +
|
|
149
|
+
`set_model can then claim it for "${inputProvider}".`);
|
|
150
|
+
}
|
|
151
|
+
const modelIdProperty = {
|
|
152
|
+
type: "string",
|
|
153
|
+
description: 'Exact string an Agent.model must equal, e.g. "claude-sonnet-5".',
|
|
154
|
+
};
|
|
155
|
+
const setModelProperties = {
|
|
156
|
+
provider: { type: "string", enum: [...MODEL_PROVIDERS], description: "Which adapter routes calls to this model." },
|
|
157
|
+
modelId: modelIdProperty,
|
|
158
|
+
encoding: {
|
|
159
|
+
type: "string",
|
|
160
|
+
enum: [...TOKENIZER_ENCODINGS],
|
|
161
|
+
description: "Tokenizer encoding used to pre-count tokens for budget enforcement before any call.",
|
|
162
|
+
},
|
|
163
|
+
inputPerMTok: {
|
|
164
|
+
type: "number",
|
|
165
|
+
minimum: 0,
|
|
166
|
+
description: "USD per million input tokens, the provider's published rate.",
|
|
167
|
+
},
|
|
168
|
+
outputPerMTok: {
|
|
169
|
+
type: "number",
|
|
170
|
+
minimum: 0,
|
|
171
|
+
description: "USD per million output tokens, the provider's published rate.",
|
|
172
|
+
},
|
|
173
|
+
cachedInputPerMTok: {
|
|
174
|
+
type: "number",
|
|
175
|
+
minimum: 0,
|
|
176
|
+
description: "USD per million cache-read input tokens. The provider's own cache-read rate, never derived from inputPerMTok.",
|
|
177
|
+
},
|
|
178
|
+
cacheWritePerMTok: {
|
|
179
|
+
type: "number",
|
|
180
|
+
minimum: 0,
|
|
181
|
+
description: "USD per million cache-write input tokens. The provider's own cache-write rate, never derived from inputPerMTok.",
|
|
182
|
+
},
|
|
183
|
+
efforts: {
|
|
184
|
+
type: "array",
|
|
185
|
+
items: { type: "string", enum: [...LLM_EFFORT_LEVELS] },
|
|
186
|
+
description: "Reasoning effort levels this model accepts, lowest to highest; empty = never send one. Claude Code coding runs may send only these levels, so include the model's default (usually high; medium on some models).",
|
|
187
|
+
},
|
|
188
|
+
thinkingMode: {
|
|
189
|
+
type: "string",
|
|
190
|
+
enum: [...THINKING_MODES],
|
|
191
|
+
description: '"adaptive" (effort-based), "manual" (fixed budget_tokens, no effort), or "none" (no thinking parameter).',
|
|
192
|
+
},
|
|
193
|
+
sourceUrl: {
|
|
194
|
+
type: "string",
|
|
195
|
+
description: "The provider's own pricing page for this exact model (https). Rates must be its published numbers, never derived.",
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
export function registerModelCatalogTools(mcp, deps = {}) {
|
|
199
|
+
mcp.registerTool({
|
|
200
|
+
name: "list_models",
|
|
201
|
+
scope: "agents:read",
|
|
202
|
+
description: "The merged model catalog (shipped models overlaid with this deployment's overrides): id, pricing, encoding, effort levels, and whether this deployment can route to it right now (routable).",
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: "object",
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
properties: {
|
|
207
|
+
includeDisabled: { type: "boolean", description: "Also return models an admin has disabled." },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
handler: async (rawArgs, ctx) => {
|
|
211
|
+
const { includeDisabled } = parse(ListModelsSchema, "list_models arguments", rawArgs);
|
|
212
|
+
const catalog = currentModelCatalog();
|
|
213
|
+
const llm = ctx.providers.llm;
|
|
214
|
+
const result = {
|
|
215
|
+
models: catalog.entries().map((entry) => view(entry, llm)),
|
|
216
|
+
};
|
|
217
|
+
if (includeDisabled)
|
|
218
|
+
result.disabled = catalog.disabledEntries().map((entry) => view(entry, llm));
|
|
219
|
+
return textResult(result);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
mcp.registerTool({
|
|
223
|
+
name: "get_model",
|
|
224
|
+
scope: "agents:read",
|
|
225
|
+
description: "One catalog entry in full, active or disabled. For an override of a shipped model, also returns the shipped entry it shadows.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
type: "object",
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
required: ["modelId"],
|
|
230
|
+
properties: { modelId: modelIdProperty },
|
|
231
|
+
},
|
|
232
|
+
handler: async (rawArgs, ctx) => {
|
|
233
|
+
const { modelId } = parse(ModelIdArgSchema, "get_model arguments", rawArgs);
|
|
234
|
+
const catalog = currentModelCatalog();
|
|
235
|
+
const entry = catalog.get(modelId) ?? catalog.disabledEntries().find((e) => e.modelId === modelId);
|
|
236
|
+
if (!entry) {
|
|
237
|
+
throw new McpError(404, `Model "${modelId}" is not in the catalog (neither active nor disabled). See list_models.`);
|
|
238
|
+
}
|
|
239
|
+
const llm = ctx.providers.llm;
|
|
240
|
+
const shipped = entry.origin === "override" ? catalog.shippedEntry(modelId) : undefined;
|
|
241
|
+
return textResult({ model: view(entry, llm), ...(shipped ? { shipped } : {}) });
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
mcp.registerTool({
|
|
245
|
+
name: "set_model",
|
|
246
|
+
scope: "models:admin",
|
|
247
|
+
description: "Model managers (models:admin): add or override a catalog entry. Every field is required — there is no partial update, so the whole entry is always literal and auditable. Refused (409) when the model id belongs to a different provider: a shipped id always belongs to its shipped provider and can never be reassigned; a non-shipped id already claimed by another provider (its row active or disabled) is freed only by reset_model on that id, never by disable_model. Zero rates come back as warnings, not errors.",
|
|
248
|
+
inputSchema: {
|
|
249
|
+
type: "object",
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
required: [
|
|
252
|
+
"provider",
|
|
253
|
+
"modelId",
|
|
254
|
+
"encoding",
|
|
255
|
+
"inputPerMTok",
|
|
256
|
+
"outputPerMTok",
|
|
257
|
+
"cachedInputPerMTok",
|
|
258
|
+
"cacheWritePerMTok",
|
|
259
|
+
"efforts",
|
|
260
|
+
"thinkingMode",
|
|
261
|
+
"sourceUrl",
|
|
262
|
+
],
|
|
263
|
+
properties: setModelProperties,
|
|
264
|
+
},
|
|
265
|
+
handler: async (rawArgs, ctx) => {
|
|
266
|
+
const input = parse(SetModelSchema, "set_model arguments", rawArgs);
|
|
267
|
+
const { sourceUrl, ...entry } = input;
|
|
268
|
+
// Ownership is checked against the database directly, not the process's
|
|
269
|
+
// (possibly stale, poll-interval-old) installed catalog: a row written
|
|
270
|
+
// by another process/replica must be caught even if this process
|
|
271
|
+
// hasn't refreshed since. The static shipped catalog is never stale,
|
|
272
|
+
// so it's safe to read from the in-memory catalog either way. The
|
|
273
|
+
// check is buildCatalog's own ownership function (ownerOf), not "any
|
|
274
|
+
// different-provider row blocks": for a shipped id, an orphaned
|
|
275
|
+
// other-provider row (dead in the merge no matter what) must not
|
|
276
|
+
// falsely block the true (shipped) owner, and must not be let through
|
|
277
|
+
// just because it happens to share its own provider with input.provider.
|
|
278
|
+
// Every raw row counts as a claim here, malformed ones included (unlike
|
|
279
|
+
// the merge, which never sees them): a malformed row still occupies the
|
|
280
|
+
// id, and reset_model is what clears it.
|
|
281
|
+
const existingRows = await ctx.db.modelCatalogEntry.findMany({ where: { modelId: input.modelId } });
|
|
282
|
+
const shippedEntry = currentModelCatalog().shippedEntry(input.modelId);
|
|
283
|
+
const owner = ownerOf(shippedEntry, existingRows);
|
|
284
|
+
if (owner && owner.provider !== input.provider) {
|
|
285
|
+
throw conflictError(input.modelId, owner.provider, owner.enabled, !!shippedEntry);
|
|
286
|
+
}
|
|
287
|
+
const ownRow = existingRows.find((row) => row.provider === input.provider);
|
|
288
|
+
const parsedOwnRow = ownRow ? rowFromRecord(ownRow) : null;
|
|
289
|
+
const before = parsedOwnRow
|
|
290
|
+
? entryOf(parsedOwnRow)
|
|
291
|
+
: shippedEntry && shippedEntry.provider === input.provider
|
|
292
|
+
? entryOf(shippedEntry)
|
|
293
|
+
: null;
|
|
294
|
+
const data = { ...entry, efforts: [...entry.efforts], enabled: true, sourceUrl, updatedBy: ctx.principal.id };
|
|
295
|
+
const row = await ctx.db.modelCatalogEntry.upsert({
|
|
296
|
+
where: { provider_modelId: { provider: input.provider, modelId: input.modelId } },
|
|
297
|
+
create: data,
|
|
298
|
+
update: data,
|
|
299
|
+
});
|
|
300
|
+
// Audited from the data we know was written — independent of whether
|
|
301
|
+
// the refresh below succeeds, so a transient refresh failure can
|
|
302
|
+
// never leave a committed write un-audited.
|
|
303
|
+
const after = entryOf(entry);
|
|
304
|
+
audit(ctx, "set", { provider: input.provider, modelId: input.modelId, sourceUrl, before, after });
|
|
305
|
+
const warnings = RATE_FIELDS.filter((field) => input[field] === 0).map((field) => `${field} is 0: confirm against ${sourceUrl}`);
|
|
306
|
+
const refreshed = await refreshBestEffort(deps.refresh, { action: "set", modelId: input.modelId });
|
|
307
|
+
// Two processes racing to claim the same id under different providers
|
|
308
|
+
// can both write (the unique key is (provider, modelId), not modelId
|
|
309
|
+
// alone); buildCatalog then silently picks one winner. If a refresh
|
|
310
|
+
// just told us the merge didn't pick us, our own row is dead on
|
|
311
|
+
// arrival — report an error rather than success.
|
|
312
|
+
if (refreshed) {
|
|
313
|
+
const nowOwner = currentModelCatalog().get(input.modelId)?.provider;
|
|
314
|
+
if (nowOwner && nowOwner !== input.provider) {
|
|
315
|
+
throw raceLostError(input.modelId, input.provider, nowOwner);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const llm = ctx.providers.llm;
|
|
319
|
+
const resolved = {
|
|
320
|
+
...entry,
|
|
321
|
+
origin: "override",
|
|
322
|
+
priceVersion: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : new Date().toISOString(),
|
|
323
|
+
sourceUrl,
|
|
324
|
+
updatedBy: ctx.principal.id,
|
|
325
|
+
...(shippedEntry ? { shippedDiffers: !sameEntry(shippedEntry, entry) } : {}),
|
|
326
|
+
};
|
|
327
|
+
return textResult({ model: view(resolved, llm), warnings });
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
mcp.registerTool({
|
|
331
|
+
name: "disable_model",
|
|
332
|
+
scope: "models:admin",
|
|
333
|
+
description: "Model managers (models:admin): remove a model from routing without deleting its history. Keeps a disabled row under its own provider so no other provider can claim the id until reset_model clears it.",
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: "object",
|
|
336
|
+
additionalProperties: false,
|
|
337
|
+
required: ["modelId"],
|
|
338
|
+
properties: { modelId: modelIdProperty },
|
|
339
|
+
},
|
|
340
|
+
handler: async (rawArgs, ctx) => {
|
|
341
|
+
const { modelId } = parse(ModelIdArgSchema, "disable_model arguments", rawArgs);
|
|
342
|
+
const catalog = currentModelCatalog();
|
|
343
|
+
const active = catalog.get(modelId);
|
|
344
|
+
if (!active) {
|
|
345
|
+
throw new McpError(404, `No active model "${modelId}" to disable (it may already be disabled, or unknown). See list_models.`);
|
|
346
|
+
}
|
|
347
|
+
const before = entryOf(active);
|
|
348
|
+
const sourceUrl = active.sourceUrl ?? `shipped:${SHIPPED_CATALOG_VERSION}`;
|
|
349
|
+
const data = {
|
|
350
|
+
...before,
|
|
351
|
+
efforts: [...before.efforts],
|
|
352
|
+
enabled: false,
|
|
353
|
+
sourceUrl,
|
|
354
|
+
updatedBy: ctx.principal.id,
|
|
355
|
+
};
|
|
356
|
+
const row = await ctx.db.modelCatalogEntry.upsert({
|
|
357
|
+
where: { provider_modelId: { provider: active.provider, modelId } },
|
|
358
|
+
create: data,
|
|
359
|
+
update: data,
|
|
360
|
+
});
|
|
361
|
+
// Disabling never changes the core CatalogEntry fields, so "after" is
|
|
362
|
+
// "before" — audited from the write we just made, not a post-refresh
|
|
363
|
+
// re-read, same reasoning as set_model.
|
|
364
|
+
const after = before;
|
|
365
|
+
audit(ctx, "disable", { provider: active.provider, modelId, sourceUrl, before, after });
|
|
366
|
+
await refreshBestEffort(deps.refresh, { action: "disable", modelId });
|
|
367
|
+
const llm = ctx.providers.llm;
|
|
368
|
+
const shippedEntry = catalog.shippedEntry(modelId);
|
|
369
|
+
const resolved = {
|
|
370
|
+
...before,
|
|
371
|
+
origin: "override",
|
|
372
|
+
priceVersion: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : new Date().toISOString(),
|
|
373
|
+
sourceUrl,
|
|
374
|
+
updatedBy: ctx.principal.id,
|
|
375
|
+
...(shippedEntry ? { shippedDiffers: !sameEntry(shippedEntry, before) } : {}),
|
|
376
|
+
};
|
|
377
|
+
return textResult({ disabled: view(resolved, llm) });
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
mcp.registerTool({
|
|
381
|
+
name: "reset_model",
|
|
382
|
+
scope: "models:admin",
|
|
383
|
+
description: "Model managers (models:admin): remove every catalog row for a model id, reverting to the shipped entry (if any) or removing it entirely. 404 if there is no row to remove (an unmodified shipped model, or an unknown id).",
|
|
384
|
+
inputSchema: {
|
|
385
|
+
type: "object",
|
|
386
|
+
additionalProperties: false,
|
|
387
|
+
required: ["modelId"],
|
|
388
|
+
properties: { modelId: modelIdProperty },
|
|
389
|
+
},
|
|
390
|
+
handler: async (rawArgs, ctx) => {
|
|
391
|
+
const { modelId } = parse(ModelIdArgSchema, "reset_model arguments", rawArgs);
|
|
392
|
+
// The 404 decision and the audited `providers` list are both based on
|
|
393
|
+
// the RAW rows, not rows that parse cleanly through rowFromRecord: a
|
|
394
|
+
// row can be malformed (fails parsing) yet still exist and still
|
|
395
|
+
// occupy the id. Deciding "nothing to reset" or omitting a provider
|
|
396
|
+
// from the audit based on parse success would delete a malformed row
|
|
397
|
+
// silently and leave the id permanently stuck — set_model's
|
|
398
|
+
// DB-sourced conflict check would keep seeing it (via ownerOf)
|
|
399
|
+
// and refusing every provider forever, with no tool able to clear it.
|
|
400
|
+
const rawRows = await ctx.db.modelCatalogEntry.findMany({ where: { modelId } });
|
|
401
|
+
if (rawRows.length === 0) {
|
|
402
|
+
throw new McpError(404, `No catalog row for "${modelId}" to reset (it is either unmodified shipped, or unknown).`);
|
|
403
|
+
}
|
|
404
|
+
const providers = [...new Set(rawRows.map((row) => String(row.provider)))];
|
|
405
|
+
const parsedRows = rawRows.map((row) => rowFromRecord(row)).filter((row) => row !== null);
|
|
406
|
+
const before = parsedRows.length > 0 ? entryOf(parsedRows[0]) : null;
|
|
407
|
+
const sourceUrl = parsedRows[0]?.sourceUrl;
|
|
408
|
+
await ctx.db.modelCatalogEntry.deleteMany({ where: { modelId } });
|
|
409
|
+
// The shipped entry is static (compiled-in), never stale, so "after"
|
|
410
|
+
// and the response's "now" can both be built from it directly,
|
|
411
|
+
// without depending on the refresh below at all.
|
|
412
|
+
const shippedEntry = currentModelCatalog().shippedEntry(modelId);
|
|
413
|
+
const after = shippedEntry ? entryOf(shippedEntry) : null;
|
|
414
|
+
audit(ctx, "reset", { provider: providers[0], providers, modelId, sourceUrl, before, after });
|
|
415
|
+
await refreshBestEffort(deps.refresh, { action: "reset", modelId });
|
|
416
|
+
const llm = ctx.providers.llm;
|
|
417
|
+
const now = shippedEntry
|
|
418
|
+
? view({ ...shippedEntry, origin: "shipped", priceVersion: `shipped:${SHIPPED_CATALOG_VERSION}` }, llm)
|
|
419
|
+
: null;
|
|
420
|
+
return textResult({ reset: modelId, now });
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
@@ -19,7 +19,7 @@ import { requireScope } from "../auth/resource-server.js";
|
|
|
19
19
|
import { McpError } from "../errors.js";
|
|
20
20
|
import { textResult } from "./text-result.js";
|
|
21
21
|
const PROVIDERS = ["github"];
|
|
22
|
-
const TRIGGERS = ["pull_request", "mention"];
|
|
22
|
+
const TRIGGERS = ["pull_request", "mention", "push"];
|
|
23
23
|
const CHECK_NAME = /^[A-Za-z0-9][A-Za-z0-9 ._/()-]{0,99}$/;
|
|
24
24
|
function normalizeRepository(provider, repository) {
|
|
25
25
|
try {
|
|
@@ -54,6 +54,7 @@ export function registerRepositoryTools(mcp) {
|
|
|
54
54
|
name: "link_repository",
|
|
55
55
|
scope: "agents:write",
|
|
56
56
|
description: "Links a native agent you own to a repository on a code-review host, with its access, event triggers, and checkName. " +
|
|
57
|
+
"Triggers: pull_request (review each PR push), mention (answer @mentions), push (start on merges to the default branch). " +
|
|
57
58
|
"Your linked GitHub account (link_host_account) must have write access to the repository for a write link, or read for a " +
|
|
58
59
|
"read link; a wardby admin may instead approve it with adminOverride. checkName is only allowed (and required) with the " +
|
|
59
60
|
"pull_request trigger, and is unique per repository. Re-linking an already-linked repository replaces its access, " +
|
|
@@ -20,10 +20,10 @@ import type { WardbyMcpServer } from "../server.js";
|
|
|
20
20
|
export declare function projectTool(tool: Tool, principalId: string): {
|
|
21
21
|
name: string;
|
|
22
22
|
code: string;
|
|
23
|
-
id: string;
|
|
24
|
-
ownerId: string | null;
|
|
25
23
|
createdAt: Date;
|
|
26
24
|
updatedAt: Date;
|
|
25
|
+
id: string;
|
|
26
|
+
ownerId: string | null;
|
|
27
27
|
description: string;
|
|
28
28
|
paramsZod: string;
|
|
29
29
|
jsonSchema: import("@prisma/client/runtime/client").JsonValue;
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* gets 404, not another principal's final text/cost.
|
|
30
30
|
*/
|
|
31
31
|
import { dispatchRun } from "../../core/dispatch.js";
|
|
32
|
+
import { AttributionError, explicitAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET, } from "../../core/attribution.js";
|
|
32
33
|
import { CodingBaseRefSchema, CodingTaskOverrideSchema } from "../../coding/protocol.js";
|
|
33
34
|
import { z } from "zod";
|
|
34
35
|
import { McpError } from "../errors.js";
|
|
@@ -42,6 +43,7 @@ const TriggerAgentSchema = z
|
|
|
42
43
|
agentId: z.string().trim().min(1).max(128),
|
|
43
44
|
task: CodingTaskOverrideSchema.optional(),
|
|
44
45
|
baseRef: CodingBaseRefSchema.optional(),
|
|
46
|
+
issue: z.unknown().optional(),
|
|
45
47
|
})
|
|
46
48
|
.strict();
|
|
47
49
|
function parseTriggerArgs(args) {
|
|
@@ -81,7 +83,17 @@ export function registerTriggerTool(mcp) {
|
|
|
81
83
|
inputSchema: {
|
|
82
84
|
type: "object",
|
|
83
85
|
additionalProperties: false,
|
|
84
|
-
properties: {
|
|
86
|
+
properties: {
|
|
87
|
+
agentId: { type: "string" },
|
|
88
|
+
task: { type: "string" },
|
|
89
|
+
baseRef: { type: "string" },
|
|
90
|
+
issue: {
|
|
91
|
+
type: "object",
|
|
92
|
+
description: "Count this run's cost toward an issue in a project the agent is linked to.",
|
|
93
|
+
properties: { provider: { type: "string", enum: ["jira"] }, key: { type: "string" } },
|
|
94
|
+
required: ["provider", "key"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
85
97
|
required: ["agentId"],
|
|
86
98
|
},
|
|
87
99
|
handler: async (rawArgs, ctx) => {
|
|
@@ -90,13 +102,28 @@ export function registerTriggerTool(mcp) {
|
|
|
90
102
|
if (agent.kind !== "coding" && (args.task !== undefined || args.baseRef !== undefined)) {
|
|
91
103
|
throw new McpError(400, "Task and baseRef overrides are only valid for coding agents.");
|
|
92
104
|
}
|
|
105
|
+
// Refused before any run exists: an invalid or unlinked issue is never
|
|
106
|
+
// silently dropped into an unattributed run.
|
|
107
|
+
let attribution;
|
|
108
|
+
if (args.issue !== undefined) {
|
|
109
|
+
try {
|
|
110
|
+
attribution = await explicitAttribution(ctx.db, ctx.providers.issueTrackers, agent.id, args.issue, RESPONSE_PATH_SNAPSHOT_BUDGET);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err instanceof AttributionError)
|
|
114
|
+
throw new McpError(400, err.message);
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
93
118
|
const dispatched = await dispatchRun({
|
|
94
119
|
db: ctx.db,
|
|
95
120
|
executor: ctx.providers.executor,
|
|
121
|
+
selfDefects: { db: ctx.db, issueTrackers: ctx.providers.issueTrackers },
|
|
96
122
|
agentId: agent.id,
|
|
97
123
|
trigger: "manual",
|
|
98
124
|
codingTask: args.task,
|
|
99
125
|
codingBaseRef: args.baseRef,
|
|
126
|
+
attribution,
|
|
100
127
|
task: ctx.clientSupportsTasks ? { principalId: ctx.principal.id, ttlMs: DEFAULT_TASK_TTL_MS } : undefined,
|
|
101
128
|
triggeredById: ctx.principal.id,
|
|
102
129
|
// Re-checked through the transaction: a concurrent revoke or
|
|
@@ -113,10 +140,11 @@ export function registerTriggerTool(mcp) {
|
|
|
113
140
|
throw new Error("Run task was not persisted.");
|
|
114
141
|
return textResult(createTaskResult(dispatched.task, DEFAULT_TASK_TTL_MS));
|
|
115
142
|
}
|
|
116
|
-
// A run refused at dispatch (its budget group or run tree is spent)
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
// A run refused at dispatch (its budget group or run tree is spent), or
|
|
144
|
+
// failed there (a coding agent's model is unavailable), is already
|
|
145
|
+
// terminal: say so now rather than only through get_run.
|
|
146
|
+
if (dispatched.run.status === "refused" || dispatched.run.status === "failed") {
|
|
147
|
+
return textResult({ runId: dispatched.run.id, status: dispatched.run.status, error: dispatched.run.error });
|
|
120
148
|
}
|
|
121
149
|
return textResult({ runId: dispatched.run.id });
|
|
122
150
|
},
|
|
@@ -6,7 +6,9 @@ import type { SelfHostedAuthProvider } from "../../providers/auth/self-hosted.js
|
|
|
6
6
|
import type { WardbyMcpServer } from "../server.js";
|
|
7
7
|
import type { McpProviders } from "../context.js";
|
|
8
8
|
import { type GitHubIngressDeps } from "../host-events/github-ingress.js";
|
|
9
|
+
import { type JiraIngressDeps } from "../host-events/jira-ingress.js";
|
|
9
10
|
import { type HostUserCallbackDeps } from "../host-events/github-user-callback.js";
|
|
11
|
+
import { type ViewerApi } from "../../viewer/http.js";
|
|
10
12
|
export interface HttpServerConfig {
|
|
11
13
|
canonicalUri: string;
|
|
12
14
|
httpBind: {
|
|
@@ -30,11 +32,14 @@ export interface StartHttpServerOptions {
|
|
|
30
32
|
/** Code-review host webhooks; absent = the endpoints answer 404. */
|
|
31
33
|
hostEvents?: {
|
|
32
34
|
github?: GitHubIngressDeps;
|
|
35
|
+
jira?: JiraIngressDeps;
|
|
33
36
|
};
|
|
34
37
|
/** The host identity-link browser callback (link_host_account); absent = 404. */
|
|
35
38
|
hostUserAuth?: {
|
|
36
39
|
github?: HostUserCallbackDeps;
|
|
37
40
|
};
|
|
41
|
+
/** The admin viewer API (/admin/api/*); absent = those paths answer 404. */
|
|
42
|
+
viewer?: ViewerApi;
|
|
38
43
|
}
|
|
39
44
|
export interface HttpServerHandle {
|
|
40
45
|
port: number;
|
|
@@ -6,11 +6,13 @@ import { authenticate, protectedResourceMetadata } from "../auth/resource-server
|
|
|
6
6
|
import { McpError } from "../errors.js";
|
|
7
7
|
import { handleWebhookIngress } from "../webhooks/ingress.js";
|
|
8
8
|
import { handleGitHubEventIngress } from "../host-events/github-ingress.js";
|
|
9
|
+
import { handleJiraEventIngress } from "../host-events/jira-ingress.js";
|
|
9
10
|
import { handleHostUserCallback } from "../host-events/github-user-callback.js";
|
|
10
11
|
import { canonicalUrl, HTTP_LIMITS, HttpBoundaryError, parseBody, readBody } from "./http-limits.js";
|
|
11
12
|
import { browserHandler } from "../auth/self-hosted/browser.js";
|
|
12
13
|
import { handleSecretElicitationForm, SECRET_ELICITATION_PATH } from "../tools/secret-elicitation-form.js";
|
|
13
14
|
import { logger } from "../../core/logger.js";
|
|
15
|
+
import { VIEWER_API_PREFIX } from "../../viewer/http.js";
|
|
14
16
|
const httpLog = logger.child({ module: "streamable-http" });
|
|
15
17
|
export function sendJson(res, status, body, headers = {}) {
|
|
16
18
|
res
|
|
@@ -119,7 +121,7 @@ export async function startHttpServer(opts) {
|
|
|
119
121
|
if (!body || Array.isArray(body) || body instanceof URLSearchParams)
|
|
120
122
|
throw new HttpBoundaryError(415, "expected_json_object");
|
|
121
123
|
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
|
|
122
|
-
const result = await handleWebhookIngress(webhook[1], { headers, body: body }, opts.auth.db, opts.auth.providers.executor);
|
|
124
|
+
const result = await handleWebhookIngress(webhook[1], { headers, body: body }, opts.auth.db, opts.auth.providers.executor, opts.auth.providers.issueTrackers);
|
|
123
125
|
sendJson(res, result.status, result.body);
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
@@ -136,6 +138,19 @@ export async function startHttpServer(opts) {
|
|
|
136
138
|
}
|
|
137
139
|
return;
|
|
138
140
|
}
|
|
141
|
+
if (url.pathname === "/hosts/jira/events" && req.method === "POST") {
|
|
142
|
+
if (!opts.hostEvents?.jira) {
|
|
143
|
+
sendJson(res, 404, { error: "not_found" });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
|
|
147
|
+
const result = await handleJiraEventIngress({ headers, rawBody: rawBody ?? "" }, opts.hostEvents.jira);
|
|
148
|
+
sendJson(res, result.status, result.body);
|
|
149
|
+
if (result.afterResponse) {
|
|
150
|
+
void result.afterResponse().catch((err) => httpLog.warn({ err }, "host event follow-up failed"));
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
139
154
|
if (url.pathname === "/hosts/github/user-callback" && req.method === "GET") {
|
|
140
155
|
// Unauthenticated by design (a browser redirect from GitHub): the
|
|
141
156
|
// single-use, hashed OAuth state is what binds it to a link request,
|
|
@@ -147,6 +162,12 @@ export async function startHttpServer(opts) {
|
|
|
147
162
|
await handleHostUserCallback(url, res, opts.hostUserAuth.github);
|
|
148
163
|
return;
|
|
149
164
|
}
|
|
165
|
+
if (url.pathname.startsWith(VIEWER_API_PREFIX)) {
|
|
166
|
+
if (opts.viewer && (await opts.viewer.handle(req, res, url)))
|
|
167
|
+
return;
|
|
168
|
+
sendJson(res, 404, { error: "not_found" });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
150
171
|
if (url.pathname === "/mcp") {
|
|
151
172
|
if (req.method === "POST" && (!body || body instanceof URLSearchParams))
|
|
152
173
|
throw new HttpBoundaryError(415, "expected_json");
|
|
@@ -181,6 +202,7 @@ export async function startHttpServer(opts) {
|
|
|
181
202
|
return {
|
|
182
203
|
port: typeof address === "object" && address ? address.port : opts.config.httpBind.port,
|
|
183
204
|
close: () => new Promise((resolve, reject) => {
|
|
205
|
+
opts.viewer?.closeStreams();
|
|
184
206
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
185
207
|
server.closeIdleConnections();
|
|
186
208
|
}),
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { PrismaClient } from "#prisma";
|
|
8
8
|
import type { Executor } from "../../providers/executor/types.js";
|
|
9
|
+
import type { IssueTrackerRegistry } from "../../providers/issue-tracker/types.js";
|
|
9
10
|
export interface WebhookIngressRequest {
|
|
10
11
|
headers: Record<string, string | undefined>;
|
|
11
12
|
body: Record<string, unknown>;
|
|
@@ -14,4 +15,4 @@ export interface WebhookIngressResult {
|
|
|
14
15
|
status: number;
|
|
15
16
|
body: Record<string, unknown>;
|
|
16
17
|
}
|
|
17
|
-
export declare function handleWebhookIngress(webhookId: string, req: WebhookIngressRequest, db: PrismaClient, executor: Executor): Promise<WebhookIngressResult>;
|
|
18
|
+
export declare function handleWebhookIngress(webhookId: string, req: WebhookIngressRequest, db: PrismaClient, executor: Executor, issueTrackers?: IssueTrackerRegistry): Promise<WebhookIngressResult>;
|