diff --git a/docs/plans/hosted-mcp-connection-scope.md b/docs/plans/hosted-mcp-connection-scope.md index 417fe30c31..b6be8bce2f 100644 --- a/docs/plans/hosted-mcp-connection-scope.md +++ b/docs/plans/hosted-mcp-connection-scope.md @@ -332,7 +332,7 @@ The refusal must also hold where the tool runs, not only where the entry is offe ## 8. The trail records whose credential ran -`ToolCallRecord` (`models/ToolCall.ts:62–75`) records the grant, the agent, the tool and the outcome, but not whose credential ran. With per-person Connections, that is the first question a trail reader asks: whose Linear was this? +`ToolCallRecord` (`models/ToolCall.ts:62–84`) records the grant, the agent, the tool and the outcome. Until #2028 it did not record whose credential ran, and with per-person Connections that is the first question a trail reader asks: whose Linear was this? #2028 built the paragraph below (`credentialOwnerId`, `:74`), and TASK-263 added a failed vendor answer's status and first line (`vendorStatus` and `vendorError`, `:80–81`; §9). The code this paragraph cites is read at `main` `be32f102`. **Every record gains `credentialOwnerId`, the Connection's `createdBy` at call time, for every type.** That includes `github-app`, whose owner is the admin who created the row. It is copied onto the record, because once §10.5's last step deletes the row there is nothing left to join to. The copy holds against the row's delete, not the person's: once a Commonly account is deleted (§7), the id resolves to no one. A name beside it would hold against both, but how long a deleted person's name is kept is a retention decision for account deletion as a whole, and this column does not make it. Soft-deleting the row instead was considered and rejected: §10.5 ends with the delete, and a soft-deleted row is one that the sweeps, the catalogue and the mint would all have to learn to skip. @@ -344,7 +344,8 @@ The refusal must also hold where the tool runs, not only where the entry is offe | `invalid_grant` on that refresh | fails the call | `refused`, `reconnect_required` | `status: 'error'`, "reconnect"; only the generation holder writes it (§10.3), and later calls are refused as §10.3 says | | 403 or `insufficient_scope` | fails the call | `refused`, `provider_denied` (§10.4) | recorded, so the page can say what was refused; the grant is not touched | | 429 | fails the call | `failed`, `provider_rate_limited`, with `Retry-After` when one is sent | untouched | -| 5xx, a timeout, no connection | fails the call | `failed`, `provider_error` or `provider_unreachable` | untouched | +| 5xx, a timeout, no connection | fails the call | `failed`, `provider_error` or `provider_unreachable`; a vendor's answer keeps its status and first line (below) | untouched | +| 404 on a request that carried a session id, the `initialized` notification included | opens a new session once, then sends that request once more (below) | as the retry ends; a second 404 is `failed`, `provider_error` | untouched | | a result with `isError: true` | preserves the vendor content and marks the tool result as an error | `failed`, `upstream_tool_error` | untouched | | a pinned tool missing, or drifted (§3) | refuses before calling | `refused`, `tool_unavailable` or `tool_drift` | untouched | | an argument the pin does not declare (§3) | refuses before calling, parking or spending budget | `refused`, `invalid_tool_args` | untouched | @@ -354,11 +355,17 @@ The refusal must also hold where the tool runs, not only where the entry is offe `refused` is for answers about authority, whether ours or the vendor's; `failed` is for a call that could not complete (`ToolCallOutcome`, `models/ToolCall.ts:21`). Each row reaches the person as a named state, never as a silent empty result. -**A parked call ends once, and its ending is trailed** (TASK-208). The park writes `pending_approval`, `approval_required` (`toolBrokerService.ts:1217`), and the ending writes one more row under the same `approvalId`: +The 401, 403 and 429 rows are not built yet. At `main` `be32f102` a vendor 401 or 403 fails the call at once, `refused`, `credential_rejected` (`services/hostedMcpToolDefinitions.ts:224–228`), and a 429 fails it `failed`, `provider_error`. A token is refreshed only before a list walk or a call, when its recorded lifetime is missing or ends within a minute (`isFresh`, `services/hostedMcpCredentialService.ts:305–315`). -- approved: the execution's own row, as for any call (`:1322`, or `:1332` when it throws): `ok`, `failed`, or `refused` with the code, whether a check of ours stops it before the provider or the vendor refuses it; -- declined: `refused`, `approval_declined` (`approvalActionService.ts:725`); -- expired: `refused`, `approval_expired`. The decide path writes it (`:627`). The usual path is the scheduler's five-minute sweep, `sweepExpiredToolCallApprovals` (`approvalActionService.ts:766`, scheduled at `schedulerService.ts:491`), which writes no row, so a call it expires ends with nothing in the trail after `pending_approval`. The build writes the same row there. +**Each list walk and each call opens its own MCP session** (TASK-263, 2026-10-22). It is one Streamable HTTP session under the row's credential (`openHostedMcpSession`, `services/hostedMcpToolDefinitions.ts:197–318`): `initialize`, then the `notifications/initialized` notification, which must be answered `202`, then the walk or the call. Each request after `initialize` carries the negotiated protocol version, and the vendor's session id when it gave one. A version other than `2025-03-26`, `2025-06-18` or `2025-11-25` fails the walk or call `provider_error`. When the walk or call ends, it sends one `DELETE` for the session id the vendor gave, if it gave one (`:274–291`). The answer is not read and a request that fails is ignored, so the `DELETE` never changes the walk's or call's result. Ending the vendor's session is not guaranteed: a vendor that refuses the `DELETE`, or never receives it, may keep the session open. Nothing about a session is stored on our side. A walk or call holds its session id only while it runs and never shares or reuses it, so a refresh, a reconnect or a removal has no stored session to drop. + +**The trail keeps a failed vendor answer's status and first line** (TASK-263, 2026-10-22). When a vendor fails a hosted call with a JSON-RPC error, or with an HTTP error other than 401 or 403, at the pin check's list or at the call itself, the call's row keeps the HTTP status (`vendorStatus`) and a line of the error (`vendorError`). The direct path and an approved call's execution both write them (`toolBrokerService.ts:1341`, `:1424`). The line is the first line of a plain error body, or `JSON-RPC : ` when the error comes as JSON (`vendorErrorLine`, `services/hostedMcpToolDefinitions.ts:174–194`). It is sanitized before it is kept: the row's token, `Bearer` values, the values of `access_token`, `refresh_token`, `client_secret` and `api_key` fields, and any run of 24 or more letters, digits, `_` or `-` are redacted, and it is cut to 200 characters (`:165–172`). An HTTP error whose body runs past 4096 characters, or is JSON that does not parse or has no string `error.message`, keeps its status and no line. Nothing else of the answer is kept: not the rest of the body, not its headers, and never the token. The broker strips both fields, and the JSON-RPC code, from the error a seat receives (`stripVendorDiagnostics`, `toolBrokerService.ts:1024–1031`); a JSON-RPC error on the call still names the vendor's message in that error, sanitized the same way. The code this paragraph and the one above cite is read at `main` `be32f102`. + +**A parked call ends once, and its ending is trailed** (TASK-208, its cites re-read at `main` `be32f102`). The park writes `pending_approval`, `approval_required` (`toolBrokerService.ts:1293`), and the ending writes one more row under the same `approvalId`: + +- approved: the execution's own row, as for any call (`:1409`, or `:1424` when it throws): `ok`, `failed`, or `refused` with the code, whether a check of ours stops it before the provider or the vendor refuses it; +- declined: `refused`, `approval_declined` (`approvalActionService.ts:832`); +- expired: `refused`, `approval_expired`. The decide path writes it (`:734`). The usual path is the scheduler's five-minute sweep, `sweepExpiredToolCallApprovals` (`approvalActionService.ts:873`, scheduled at `schedulerService.ts:515`), which writes no row, so a call it expires ends with nothing in the trail after `pending_approval`. The build writes the same row there. The seat learns each ending through §5's event, and the owner sees it on the card.