From 7bc4c0d04515b37bf671cb1582774b4daefedb23 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 20:34:49 +0000 Subject: [PATCH 1/5] Save an omitted HTTP MCP connection_origin as service The official service stores an omitted or null connection_origin on an HTTP MCP tool as "service" (MV-01). Default it before the existing checks, so saved Agents, inline Session agents and per-Session replacements store exactly the explicit declaration and execution is unchanged. The environment origin and other transports keep their rejection. --- .../agents-client/src/protocol-types.test.ts | 11 +++++++ packages/agents-client/src/types.ts | 3 +- .../internal/api/mcp_configuration.go | 16 ++++++++++ .../internal/api/mcp_configuration_test.go | 26 +++++++++++++++-- services/agents-api/tests/official_agents.py | 9 ++++-- services/agents-api/tests/official_mcp.py | 29 +++++++++++++++---- 6 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/agents-client/src/protocol-types.test.ts b/packages/agents-client/src/protocol-types.test.ts index b811f0c86..ca212ddf2 100644 --- a/packages/agents-client/src/protocol-types.test.ts +++ b/packages/agents-client/src/protocol-types.test.ts @@ -12,6 +12,7 @@ import turnResources from "./fixtures/parsar-0438880a/turn-resources.json"; import toolProfiles from "./fixtures/parsar-2b34ea46/tool-profiles.json"; import type { AnonymousHttpMcpToolInput, + ServiceHttpMcpToolInput, AgentsCoreSelection, SavedAgentCoreInput, SavedAgentCore, @@ -154,6 +155,16 @@ describe("Parsar 2b34ea46 bounded tool profiles", () => { expectTypeOf[number], { type: "web_search" }>>().toEqualTypeOf(); }); + it("accepts the minimal pinned MCP tool whose omitted origin Core saves as service", () => { + expectTypeOf().toEqualTypeOf<"service" | null | undefined>(); + const minimal: ServiceHttpMcpToolInput = { + type: "mcp", + server_label: "docs", + transport: { type: "http", server_url: "https://mcp.example/tools" }, + }; + expect("connection_origin" in minimal).toBe(false); + }); + it("captures the two Web-configurable write shapes without credentials or browser MCP", () => { const functionTool = toolProfiles.write_profiles.function; const mcpTool = toolProfiles.write_profiles.anonymous_http_mcp as AnonymousHttpMcpToolInput; diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 9ad01ee13..d0e47acdf 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -64,7 +64,8 @@ export interface ServiceHttpMcpToolInput { }; /** null or omitted permits every advertised tool; [] permits none. */ allowed_tools?: string[] | null; - connection_origin: "service"; + /** null or omitted is saved as "service", as the official service does. */ + connection_origin?: "service" | null; /** Saving a reference does not authorize it; Session vault_ids must attach its owner. */ credential_id?: string | null; required?: boolean; diff --git a/services/agents-api/internal/api/mcp_configuration.go b/services/agents-api/internal/api/mcp_configuration.go index 490571cfb..e62c56ce0 100644 --- a/services/agents-api/internal/api/mcp_configuration.go +++ b/services/agents-api/internal/api/mcp_configuration.go @@ -17,6 +17,14 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { if input.Type != "mcp" || input.ServerLabel == nil || strings.TrimSpace(*input.ServerLabel) == "" { return nil, errors.New("MCP tools require type=mcp and a nonempty server_label.") } + // The official service saves an omitted or null origin on an HTTP server as + // "service" (MV-01). Defaulting it here makes the stored and frozen + // configuration identical to an explicit declaration. Other transports keep + // the explicit requirement. + if input.ConnectionOrigin == nil && mcpHTTPTransport(input.Transport) { + service := "service" + input.ConnectionOrigin = &service + } if input.ConnectionOrigin == nil || *input.ConnectionOrigin != "service" { return nil, errors.New("MCP currently requires explicit connection_origin=service.") } @@ -72,6 +80,14 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { return json.Marshal(tool) } +// mcpHTTPTransport reports a transport object whose exact "type" member is +// "http". The complete transport is validated afterwards. +func mcpHTTPTransport(raw json.RawMessage) bool { + var fields map[string]json.RawMessage + var kind string + return json.Unmarshal(raw, &fields) == nil && json.Unmarshal(fields["type"], &kind) == nil && kind == "http" +} + func emptyMCPObject(raw json.RawMessage) bool { if len(raw) == 0 { return true diff --git a/services/agents-api/internal/api/mcp_configuration_test.go b/services/agents-api/internal/api/mcp_configuration_test.go index c9a17c640..9d37aea14 100644 --- a/services/agents-api/internal/api/mcp_configuration_test.go +++ b/services/agents-api/internal/api/mcp_configuration_test.go @@ -52,6 +52,27 @@ func TestMCPResourceTransportProjections(t *testing.T) { } } +// MV-01: an omitted or null origin on an HTTP server is stored exactly as an +// explicit "service" declaration, in saved Agents and Session configuration. +func TestMCPOmittedOriginIsService(t *testing.T) { + for _, saved := range []bool{false, true} { + explicit, err := resolveMCPTool(json.RawMessage(publicMCP), saved) + if err != nil { + t.Fatal(err) + } + for _, origin := range []string{"", `,"connection_origin":null`} { + input := `{"type":"mcp","server_label":"records"` + origin + `,"transport":{"type":"http","server_url":"https://mcp.example.test/tools"}}` + resolved, err := resolveMCPTool(json.RawMessage(input), saved) + if err != nil || string(resolved) != string(explicit) { + t.Fatalf("saved=%v origin %q: %s, %v; want %s", saved, origin, resolved, err, explicit) + } + } + } + if _, err := resolveMCPTool(json.RawMessage(`{"type":"mcp","server_label":"records","transport":{"type":"stdio","command":"run"}}`), true); err == nil || err.Error() != "MCP currently requires explicit connection_origin=service." { + t.Fatal("stdio transport lost its explicit origin rule", err) + } +} + func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { for _, allowed := range []string{"null", "[]", `["lookup","fail"]`} { var input map[string]json.RawMessage @@ -74,9 +95,10 @@ func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { for name, replacement := range map[string]map[string]json.RawMessage{ - "origin missing": {"connection_origin": nil}, - "origin null": {"connection_origin": json.RawMessage("null")}, "environment origin": {"connection_origin": json.RawMessage(`"environment"`)}, + "stdio origin missing": {"connection_origin": nil, "transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, + "stdio origin null": {"connection_origin": json.RawMessage("null"), "transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, + "origin missing, case": {"connection_origin": nil, "transport": json.RawMessage(`{"Type":"http","server_url":"https://mcp.example.test"}`)}, "required type": {"required": json.RawMessage(`"true"`)}, "required null": {"required": json.RawMessage("null")}, "empty credential": {"credential_id": json.RawMessage(`""`)}, diff --git a/services/agents-api/tests/official_agents.py b/services/agents-api/tests/official_agents.py index e1ebfd890..6cb781800 100644 --- a/services/agents-api/tests/official_agents.py +++ b/services/agents-api/tests/official_agents.py @@ -154,9 +154,14 @@ def verify_agents(client, other, invalid, expect_error): expect_error(AuthenticationError, lambda: invalid.beta.agents.create(model="x")) expect_error(BadRequestError, lambda: agents.create(model="x", extra_headers={"OpenAI-Beta": ""})) assert raw.post(base, json={"model": "x"}).status_code == 401 - # Unsupported families are explicit gaps, not schema-conformance evidence. + # The minimal pinned MCP tool saves its omitted origin as "service" (MV-01); + # the environment origin remains an explicit gap. mcp = {"type": "mcp", "server_label": "x", "transport": {"type": "http", "server_url": "https://example.invalid"}} - expect_error(BadRequestError, lambda: agents.create(model="x", tools=[mcp])) + minimal = agents.with_raw_response.create(model="x", tools=[mcp]).http_response.json() + assert minimal["tools"] == [{**mcp, "transport": {**mcp["transport"], "headers": {}}, "connection_origin": "service", + "allowed_tools": None, "credential_id": None, "request_metadata": {}, "required": False}] + assert agents.delete(minimal["id"]).deleted + expect_error(BadRequestError, lambda: agents.create(model="x", tools=[{**mcp, "connection_origin": "environment"}])) # Every pinned web_search mode is saved as the official service does (TV-05); # omitted or null mode is saved as live. A supplied location, including {}, # has all four keys (req_db41d2f6261b4abfb69465eafe719ab5, diff --git a/services/agents-api/tests/official_mcp.py b/services/agents-api/tests/official_mcp.py index 919f9753b..6a216a5e1 100644 --- a/services/agents-api/tests/official_mcp.py +++ b/services/agents-api/tests/official_mcp.py @@ -1,6 +1,5 @@ """HTTP MCP resource semantics; execution requires the separate real-provider run.""" -from copy import deepcopy from itertools import product from openai import BadRequestError, NotFoundError @@ -40,9 +39,28 @@ def verify_mcp_configuration(client, other, expect_error): recovered.extend([session, override]) saved.append(changed) + # An omitted or null origin on HTTP transport is saved exactly as "service", + # the pinned SDK's minimal tool form (MV-01). + explicit = agents.with_raw_response.create(model="requested-model", tools=[tool]).http_response.json() + minimal = {key: value for key, value in tool.items() if key != "connection_origin"} + for declaration in (minimal, {**tool, "connection_origin": None}): + response = agents.with_raw_response.create(model="requested-model", tools=[declaration]) + resource, body = response.parse(), response.http_response.json() + assert body["tools"] == explicit["tools"] and body["tools"][0]["connection_origin"] == "service" + updated = agents.update(resource.id, tools=[declaration]) + assert updated.tools == resource.tools + inline = sessions.create(agent={"model": "requested-model", "tools": [declaration]}, + input="Verify mcp fixture admission.", environment={"type": "none"}) + assert inline.to_dict()["agent"]["tools"] == [{**explicit["tools"][0], "transport": transport}] + replaced = sessions.create(agent_id=resource.id, agent={"tools": [declaration]}, + input="Verify mcp fixture admission.", environment={"type": "none"}) + assert replaced.agent.tools == inline.agent.tools + recovered.extend([inline, replaced]) + saved.append(updated) + before = {item.id for item in sessions.list()} saved_before = {item.id for item in agents.list()} - invalid = [{**tool, "connection_origin": value} for value in (None, "environment")] + invalid = [{**tool, "connection_origin": "environment"}] invalid += [{**tool, "required": "true"}, {**tool, "required": None}, {**tool, "request_metadata": {"x": "y"}}, {**tool, "allowed_tools": [None]}] @@ -50,9 +68,8 @@ def verify_mcp_configuration(client, other, expect_error): {"authorization": "synthetic-private"}, {"server_url": "https://mcp.example.invalid/mcp?token=synthetic-private"}): invalid.append({**tool, "transport": {**transport, **changes}}) - omitted = deepcopy(tool) - omitted.pop("connection_origin") - invalid.append(omitted) + # Transports other than HTTP keep the explicit-origin requirement. + invalid.append({**minimal, "transport": {"type": "stdio", "command": "synthetic-private"}}) for declaration in invalid: for operation in ( lambda: agents.create(model="requested-model", tools=[declaration]), @@ -63,5 +80,5 @@ def verify_mcp_configuration(client, other, expect_error): assert "synthetic-private" not in str(error.body) assert {item.id for item in sessions.list()} == before assert {item.id for item in agents.list()} == saved_before - print("HTTP MCP: pinned saved/Session projections, null/empty allowlists, immutable snapshots and rejected writes passed; no native execution claimed.") + print("HTTP MCP: pinned saved/Session projections, omitted/null origins, null/empty allowlists, immutable snapshots and rejected writes passed; no native execution claimed.") return recovered, saved From 692d92cf7595128b7927d70a7230b03cb3b2261d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 20:34:49 +0000 Subject: [PATCH 2/5] Project selected MCP credentials and align selection errors Session reads, lists and event snapshots now show the credential that creation implicitly selected for an MCP tool without an explicit credential_id (MV-02), also after it is deleted. Only the response changes: the stored caller intent, retries and dispatch are unchanged, and only a binding whose Vault the Session attached is shown. Selection errors use the official fields (MV-03): a reference without vault_ids, outside the attached Vaults or for another server_url is a 400 invalid_request_error, and several implicit matches a 409 conflict_error. Missing, foreign-tenant and unattached references share one message, and caller values are echoed only within the shared bound. --- services/agents-api/internal/api/errors.go | 9 + .../internal/api/session_credentials.go | 47 +++ .../internal/api/session_credentials_test.go | 74 +++- .../internal/api/session_response.go | 2 +- .../internal/db/queries/mcp_credentials.sql | 6 +- .../internal/db/sqlc/mcp_credentials.sql.go | 10 +- .../mcp_credential_selection_public_test.go | 370 ++++++++++++++++++ .../internal/store/mcp_credentials.go | 55 ++- .../internal/store/mcp_credentials_test.go | 28 +- .../store/remote_mcp_credentials_test.go | 10 +- .../internal/store/remote_mcp_test.go | 19 +- .../store/vault_credentials_delete_test.go | 4 +- .../store/vault_credentials_oauth_test.go | 4 +- .../tests/official_mcp_credentials.py | 69 +++- 14 files changed, 646 insertions(+), 61 deletions(-) create mode 100644 services/agents-api/internal/store/mcp_credential_selection_public_test.go diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index a0be13d75..e9ddcccf2 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -84,6 +84,7 @@ func writeFieldError(w http.ResponseWriter, err error) bool { func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFoundParam ...string) { var cursor *store.InvalidCursorError + var selection *store.MCPCredentialSelectionError switch { case errors.Is(err, store.ErrProjectAPIKeyExists): writeError(w, http.StatusConflict, "project_api_key_exists", "This API key ID already exists. List its metadata and revoke it explicitly if the secret was not saved.") @@ -124,6 +125,14 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound } else { writeError(w, http.StatusBadRequest, "invalid_request_error", cursor.Message) } + case errors.As(err, &selection): + // Observed official fields for Session MCP credential selection (MV-03), + // all with a null param. + if selection.Conflict { + writeError(w, http.StatusConflict, "conflict_error", selection.Message) + } else { + writeError(w, http.StatusBadRequest, "invalid_request_error", selection.Message) + } case errors.Is(err, store.ErrNotFound): code := "not_found_error" // Files and Skills retain their non-beta error envelope. diff --git a/services/agents-api/internal/api/session_credentials.go b/services/agents-api/internal/api/session_credentials.go index 6b9459bc8..bb2b514da 100644 --- a/services/agents-api/internal/api/session_credentials.go +++ b/services/agents-api/internal/api/session_credentials.go @@ -6,6 +6,7 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" ) type mcpCredentialResolver interface { @@ -44,6 +45,52 @@ func (h *Handler) bindSessionCredentials(ctx context.Context, tenant string, raw return json.Marshal(cfg) } +// projectedMCPCredential shows the credential that creation selected for an MCP +// tool without an explicit credential_id, as the official Session projection +// does (MV-02). A frozen binding names only a credential of a Vault the caller +// attached and owned at creation, and the ID stays shown after that credential +// is deleted. Anonymous selections stay null and explicit references are echoed +// unchanged. Only the response changes: the stored caller intent, creation +// retries and dispatch keep reading the configuration as stored. +func projectedMCPCredential(raw json.RawMessage, cfg configuration) json.RawMessage { + var tool v1.MCPTool + if len(cfg.MCPCredentials) == 0 || json.Unmarshal(raw, &tool) != nil || tool.Type != "mcp" || tool.CredentialID != nil { + return raw + } + for _, binding := range cfg.MCPCredentials { + if binding.ServerLabel != tool.ServerLabel || binding.ServerURL != tool.Transport.ServerURL || binding.CredentialID == "" { + continue + } + if !attachedVault(cfg.VaultIDs, binding.VaultID) { + return raw + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return raw + } + fields["credential_id"], _ = json.Marshal(binding.CredentialID) + projected, err := json.Marshal(fields) + if err != nil { + return raw + } + return projected + } + return raw +} + +func attachedVault(attached []string, vault string) bool { + selected, err := uuid.Parse(vault) + if err != nil { + return false + } + for _, raw := range attached { + if id, err := uuid.Parse(raw); err == nil && id == selected { + return true + } + } + return false +} + // Attached inline requests need recorded caller intent before reading mutable // Vault contents. Other inline requests retain their resolved/default identity. func inlineCredentialIntent(input sessionRequest) bool { diff --git a/services/agents-api/internal/api/session_credentials_test.go b/services/agents-api/internal/api/session_credentials_test.go index 5d74e2361..46433e8f3 100644 --- a/services/agents-api/internal/api/session_credentials_test.go +++ b/services/agents-api/internal/api/session_credentials_test.go @@ -8,6 +8,7 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" ) func TestMCPCredentialReferenceIsSchemaNotAuthorization(t *testing.T) { @@ -60,21 +61,72 @@ func TestSessionVaultTypesAndCreationIntent(t *testing.T) { } } -func TestSessionProjectionExcludesResolvedMCPSelection(t *testing.T) { - var tool v1.MCPTool - if json.Unmarshal([]byte(publicMCP), &tool) != nil { - t.Fatal("invalid fixture") +// MV-02: a tool without an explicit credential_id projects the credential that +// creation selected; the stored configuration is unchanged. +func TestSessionProjectionShowsSelectedMCPCredential(t *testing.T) { + vault, credential := uuid.NewString(), uuid.NewString() + tool := func(label, credentialID string) json.RawMessage { + value := v1.MCPTool{Type: "mcp", ServerLabel: label, Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: "https://mcp.example.test/" + label}, + ConnectionOrigin: "service", RequestMetadata: map[string]json.RawMessage{}} + if credentialID != "" { + value.CredentialID = &credentialID + } + encoded, _ := json.Marshal(value) + return encoded + } + binding := func(label, credentialID string) store.MCPCredentialBinding { + b := store.MCPCredentialBinding{ServerLabel: label, ServerURL: "https://mcp.example.test/" + label} + if credentialID != "" { + b.VaultID, b.CredentialID, b.AuthType = vault, credentialID, "static_bearer" + } + return b } - encodedTool, _ := json.Marshal(tool) - cfg := configuration{Agent: v1.Agent{ID: "agent", Model: "model", Tools: []json.RawMessage{encodedTool}}, Environment: v1.Environment{Type: "none"}, VaultIDs: []string{"attached"}, - MCPCredentials: []store.MCPCredentialBinding{{ServerLabel: "records", ServerURL: tool.Transport.ServerURL, VaultID: "attached", CredentialID: "private-selection", AuthType: "static_bearer"}}} + function := json.RawMessage(`{"type":"function","name":"lookup","description":"","parameters":{"type":"object"},"defer_loading":false}`) + explicit := uuid.NewString() + cfg := configuration{Agent: v1.Agent{ID: "agent", Model: "model", Tools: []json.RawMessage{tool("implicit", ""), tool("anonymous", ""), tool("explicit", strings.ToUpper(explicit)), function}}, + Environment: v1.Environment{Type: "none"}, VaultIDs: []string{strings.ToUpper(vault)}, + MCPCredentials: []store.MCPCredentialBinding{binding("implicit", credential), binding("anonymous", ""), binding("explicit", explicit)}} raw, _ := json.Marshal(cfg) + stored := string(raw) response, err := sessionResponse(store.Session{Configuration: raw}, "") - if err != nil || !reflect.DeepEqual(response.VaultIDs, cfg.VaultIDs) { - t.Fatal("public attachments lost", err) + if err != nil || !reflect.DeepEqual(response.VaultIDs, cfg.VaultIDs) || string(raw) != stored { + t.Fatal("public attachments lost or stored configuration changed", err) + } + want := []any{credential, nil, strings.ToUpper(explicit)} + for index, expected := range want { + var projected map[string]any + if json.Unmarshal(response.Agent.Tools[index], &projected) != nil || !reflect.DeepEqual(projected["credential_id"], expected) { + t.Fatalf("tool %d projected %s; want credential_id %v", index, response.Agent.Tools[index], expected) + } + var original map[string]any + _ = json.Unmarshal(cfg.Agent.Tools[index], &original) + original["credential_id"] = expected + if !reflect.DeepEqual(projected, original) { + t.Fatalf("tool %d changed beyond credential_id: %s", index, response.Agent.Tools[index]) + } + } + if string(response.Agent.Tools[3]) != string(function) { + t.Fatal("non-MCP tool changed") } public, _ := json.Marshal(response) - if strings.Contains(string(public), "private-selection") || strings.Contains(string(public), "mcp_credentials") || !strings.Contains(string(public), `"credential_id":null`) { - t.Fatal("public projection exposed implicit selection or changed caller reference") + if strings.Contains(string(public), "mcp_credentials") || strings.Contains(string(public), "static_bearer") || strings.Contains(string(public), vault) { + t.Fatal("public projection exposed private binding fields", string(public)) + } + + // A binding outside the attachments or for another server is never shown. + for _, change := range []func(*configuration){ + func(c *configuration) { c.VaultIDs = []string{uuid.NewString()} }, + func(c *configuration) { c.VaultIDs = nil }, + func(c *configuration) { c.MCPCredentials[0].ServerURL += "/other" }, + func(c *configuration) { c.MCPCredentials[0].ServerLabel = "other" }, + } { + changed := cfg + changed.MCPCredentials = append([]store.MCPCredentialBinding(nil), cfg.MCPCredentials...) + change(&changed) + raw, _ := json.Marshal(changed) + response, err := sessionResponse(store.Session{Configuration: raw}, "") + if err != nil || string(response.Agent.Tools[0]) != string(changed.Agent.Tools[0]) { + t.Fatalf("unattached or unmatched binding was projected: %s, %v", response.Agent.Tools[0], err) + } } } diff --git a/services/agents-api/internal/api/session_response.go b/services/agents-api/internal/api/session_response.go index 0d1eb70c3..dd9da8f89 100644 --- a/services/agents-api/internal/api/session_response.go +++ b/services/agents-api/internal/api/session_response.go @@ -32,7 +32,7 @@ func sessionResponse(session store.Session, executorURL string) (v1.Session, err return v1.Session{}, errors.New("unsupported stored tool configuration") } if tool.Type != "tool_search" { - tools = append(tools, raw) + tools = append(tools, projectedMCPCredential(raw, cfg)) } } cfg.Agent.Tools = tools diff --git a/services/agents-api/internal/db/queries/mcp_credentials.sql b/services/agents-api/internal/db/queries/mcp_credentials.sql index fb82e3804..e3290668e 100644 --- a/services/agents-api/internal/db/queries/mcp_credentials.sql +++ b/services/agents-api/internal/db/queries/mcp_credentials.sql @@ -3,14 +3,16 @@ SELECT id FROM vaults WHERE tenant_id = sqlc.arg(tenant_id) AND id = ANY(sqlc.arg(vault_ids)::uuid[]); -- name: FindMCPCredentials :many +-- An explicit credential ID is found in the attached Vaults by ID alone, so the +-- caller can compare its destination; otherwise the exact destination selects. SELECT c.id, c.vault_id, c.name, c.auth_type, c.mcp_server_url, c.created_at, c.updated_at FROM vault_credentials c JOIN vaults v ON v.id = c.vault_id WHERE v.tenant_id = sqlc.arg(tenant_id) AND v.id = ANY(sqlc.arg(vault_ids)::uuid[]) AND c.auth_type IN ('static_bearer', 'mcp_oauth') - AND c.mcp_server_url = sqlc.arg(mcp_server_url) - AND (sqlc.narg(credential_id)::uuid IS NULL OR c.id = sqlc.narg(credential_id)::uuid) + AND CASE WHEN sqlc.narg(credential_id)::uuid IS NULL THEN c.mcp_server_url = sqlc.arg(mcp_server_url) + ELSE c.id = sqlc.narg(credential_id)::uuid END ORDER BY c.id LIMIT 2; diff --git a/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go b/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go index 5f2322efc..9a6c183fa 100644 --- a/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go +++ b/services/agents-api/internal/db/sqlc/mcp_credentials.sql.go @@ -18,8 +18,8 @@ JOIN vaults v ON v.id = c.vault_id WHERE v.tenant_id = $1 AND v.id = ANY($2::uuid[]) AND c.auth_type IN ('static_bearer', 'mcp_oauth') - AND c.mcp_server_url = $3 - AND ($4::uuid IS NULL OR c.id = $4::uuid) + AND CASE WHEN $3::uuid IS NULL THEN c.mcp_server_url = $4 + ELSE c.id = $3::uuid END ORDER BY c.id LIMIT 2 ` @@ -27,8 +27,8 @@ LIMIT 2 type FindMCPCredentialsParams struct { TenantID pgtype.UUID `json:"tenant_id"` VaultIds []pgtype.UUID `json:"vault_ids"` - McpServerUrl string `json:"mcp_server_url"` CredentialID pgtype.UUID `json:"credential_id"` + McpServerUrl string `json:"mcp_server_url"` } type FindMCPCredentialsRow struct { @@ -41,12 +41,14 @@ type FindMCPCredentialsRow struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +// An explicit credential ID is found in the attached Vaults by ID alone, so the +// caller can compare its destination; otherwise the exact destination selects. func (q *Queries) FindMCPCredentials(ctx context.Context, arg FindMCPCredentialsParams) ([]FindMCPCredentialsRow, error) { rows, err := q.db.Query(ctx, findMCPCredentials, arg.TenantID, arg.VaultIds, - arg.McpServerUrl, arg.CredentialID, + arg.McpServerUrl, ) if err != nil { return nil, err diff --git a/services/agents-api/internal/store/mcp_credential_selection_public_test.go b/services/agents-api/internal/store/mcp_credential_selection_public_test.go new file mode 100644 index 000000000..361010b72 --- /dev/null +++ b/services/agents-api/internal/store/mcp_credential_selection_public_test.go @@ -0,0 +1,370 @@ +package store_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type selectionResponse struct { + status int + header http.Header + body string +} + +// MCP tool origin defaults and Session credential selection (MV-01..03, rows +// M1–M8) over real HTTP and PostgreSQL, with tenants A and B. +func TestMCPCredentialSelectionPublicPostgres(t *testing.T) { + // An isolated database keeps the no-write digest independent of other tests. + _, pool := store.NewManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{67}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + tenantA, tokenA, tokenB := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "selection-a", TokenSHA256: device.HashCredential(tokenA), TenantID: tenantA}, + {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "selection-b", TokenSHA256: device.HashCredential(tokenB), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + h, err := api.NewHandler(s, auth, "codex", api.WithExecution(s)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + defer server.Close() + client := pathIDClient{t: t, server: server} + send := func(token, method, path, body, key string) selectionResponse { + t.Helper() + request, err := http.NewRequest(method, server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("OpenAI-Beta", "agents=v1") + request.Header.Set("Content-Type", "application/json") + if key != "" { + request.Header.Set("Idempotency-Key", key) + } + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var raw bytes.Buffer + if _, err := raw.ReadFrom(response.Body); err != nil { + t.Fatal(err) + } + // Per-response headers; every other header takes part in comparisons. + response.Header.Del("Date") + response.Header.Del("Traceparent") + return selectionResponse{status: response.StatusCode, header: response.Header, body: raw.String()} + } + + const url, otherURL, openURL = "https://mcp.example.test/tools", "https://mcp.example.test/other", "https://open.example.test/mcp" + const canary = "selection-canary-token" + credential := func(token, vault, destination string) string { + return client.created(token, "/v1/vaults/"+vault+"/credentials", `{"name":"selection","auth":{"type":"static_bearer","mcp_server_url":"`+destination+`","token":"`+canary+`"}}`) + } + attachedA := client.created(tokenA, "/v1/vaults", `{"name":"attached"}`) + secondA := client.created(tokenA, "/v1/vaults", `{"name":"second"}`) + selectedA := credential(tokenA, attachedA, url) + otherDestinationA := credential(tokenA, attachedA, otherURL) + unattachedA := credential(tokenA, secondA, url) + vaultB := client.created(tokenB, "/v1/vaults", `{"name":"b"}`) + otherVaultB := client.created(tokenB, "/v1/vaults", `{"name":"b-other"}`) + credentialB := credential(tokenB, vaultB, url) + + tool := func(label, destination, extra string) string { + return `{"type":"mcp","server_label":"` + label + `","transport":{"type":"http","server_url":"` + destination + `"}` + extra + `}` + } + reference := func(id string) string { + encoded, _ := json.Marshal(id) + return `,"credential_id":` + string(encoded) + } + vaults := func(ids ...string) string { + encoded, _ := json.Marshal(ids) + return `,"vault_ids":` + string(encoded) + } + inline := func(tools, rest string) string { + return `{"agent":{"model":"selection-model","tools":[` + tools + `]},"environment":{"type":"none"},"input":"Select credentials."` + rest + `}` + } + failure := func(kind, message string) string { + encoded, _ := json.Marshal(message) + return `{"error":{"message":` + string(encoded) + `,"type":"` + kind + `","code":"` + kind + `","param":null}}` + "\n" + } + invalid := func(message string) string { return failure("invalid_request_error", message) } + notAttached := func(id string) string { + return invalid("MCP credential_id " + id + " was not found in an attached vault") + } + missingVault := `{"error":{"message":"Resource not found.","type":"not_found_error","code":"not_found_error","param":null}}` + "\n" + sessionTools := func(body string) []map[string]any { + t.Helper() + var session struct { + Agent struct{ Tools []map[string]any } + } + if json.Unmarshal([]byte(body), &session) != nil { + t.Fatal("invalid Session body", body) + } + return session.Agent.Tools + } + storedTools := func(session string) string { + t.Helper() + var tools string + if err := pool.QueryRow(t.Context(), "SELECT configuration->'agent'->'tools' FROM sessions WHERE id=$1", session).Scan(&tools); err != nil { + t.Fatal(err) + } + return tools + } + idOf := func(body string) string { + var value struct{ ID string } + _ = json.Unmarshal([]byte(body), &value) + return value.ID + } + + // M1: an omitted or null origin on HTTP transport is exactly "service". + savedTools := func(body string) string { + t.Helper() + var agent struct{ Tools json.RawMessage } + if json.Unmarshal([]byte(body), &agent) != nil { + t.Fatal("invalid Agent body", body) + } + return string(agent.Tools) + } + explicitAgent := send(tokenA, http.MethodPost, "/v1/agents", `{"model":"m","tools":[`+tool("records", url, `,"connection_origin":"service"`)+`]}`, "") + if explicitAgent.status != http.StatusCreated || !strings.Contains(explicitAgent.body, `"connection_origin":"service"`) { + t.Fatal("explicit saved origin", explicitAgent.status, explicitAgent.body) + } + for _, origin := range []string{"", `,"connection_origin":null`} { + created := send(tokenA, http.MethodPost, "/v1/agents", `{"model":"m","tools":[`+tool("records", url, origin)+`]}`, "") + if created.status != http.StatusCreated || savedTools(created.body) != savedTools(explicitAgent.body) { + t.Fatalf("saved origin %q: %d %s", origin, created.status, created.body) + } + updated := send(tokenA, http.MethodPost, "/v1/agents/"+idOf(created.body), `{"tools":[`+tool("records", url, origin)+`]}`, "") + if updated.status != http.StatusOK || savedTools(updated.body) != savedTools(explicitAgent.body) { + t.Fatalf("updated origin %q: %d %s", origin, updated.status, updated.body) + } + } + explicitSession := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, `,"connection_origin":"service"`), ""), "origin-explicit") + if explicitSession.status != http.StatusCreated { + t.Fatal("explicit Session origin", explicitSession.status, explicitSession.body) + } + for index, body := range []string{ + inline(tool("records", url, ""), ""), + inline(tool("records", url, `,"connection_origin":null`), ""), + `{"agent_id":"` + idOf(explicitAgent.body) + `","agent":{"tools":[` + tool("records", url, "") + `]},"environment":{"type":"none"},"input":"Select credentials."}`, + } { + created := send(tokenA, http.MethodPost, "/v1/agents/sessions", body, "") + if created.status != http.StatusCreated || !reflect.DeepEqual(sessionTools(created.body), sessionTools(explicitSession.body)) || + storedTools(idOf(created.body)) != storedTools(idOf(explicitSession.body)) { + t.Fatalf("Session origin case %d: %d %s", index, created.status, created.body) + } + } + // A Session created with an explicit origin, as before this change, recovers + // from a same-key retry that omits it: the resolved configuration is equal. + if retry := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, ""), ""), "origin-explicit"); retry.status != http.StatusCreated || retry.body != explicitSession.body { + t.Fatal("same-key retry without origin", retry.status, retry.body) + } + // Recorded caller intent (attached Vaults) keeps comparing the request itself. + attachedExplicit := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, `,"connection_origin":"service"`), vaults(attachedA)), "origin-attached") + if attachedExplicit.status != http.StatusCreated { + t.Fatal("attached explicit origin", attachedExplicit.status, attachedExplicit.body) + } + if retry := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, ""), vaults(attachedA)), "origin-attached"); retry.status != http.StatusConflict || !strings.Contains(retry.body, `"code":"idempotency_conflict"`) { + t.Fatal("recorded intent retry changed", retry.status, retry.body) + } + referenced := client.created(tokenA, "/v1/agents", `{"model":"m","tools":[`+tool("records", url, reference(selectedA))+`]}`) + + // M3–M8: every rejection writes nothing. + before := databaseDigest(t, pool) + long := strings.Repeat("c", 300) + longURL := "https://mcp.example.test/" + strings.Repeat("u", 300) + for _, tc := range []struct { + name, token, body string + status int + want string + }{ + {"M3 omitted vault_ids", tokenA, inline(tool("records", url, reference(selectedA)), ""), 400, invalid("MCP credential_id requires an attached vault")}, + {"M3 null vault_ids", tokenA, inline(tool("records", url, reference(selectedA)), `,"vault_ids":null`), 400, invalid("MCP credential_id requires an attached vault")}, + {"M3 empty vault_ids", tokenA, inline(tool("records", url, reference(selectedA)), `,"vault_ids":[]`), 400, invalid("MCP credential_id requires an attached vault")}, + {"M3 saved reference", tokenA, `{"agent_id":"` + referenced + `","environment":{"type":"none"},"input":"Select credentials."}`, 400, invalid("MCP credential_id requires an attached vault")}, + {"M4 foreign tenant", tokenA, inline(tool("records", url, reference(credentialB)), vaults(attachedA)), 400, notAttached(credentialB)}, + {"M4 unattached", tokenA, inline(tool("records", url, reference(unattachedA)), vaults(attachedA)), 400, notAttached(unattachedA)}, + {"M4 unattached B", tokenB, inline(tool("records", url, reference(credentialB)), vaults(otherVaultB)), 400, notAttached(credentialB)}, + {"M4 missing", tokenA, inline(tool("records", url, reference(store.UnknownResourceID)), vaults(attachedA)), 400, notAttached(store.UnknownResourceID)}, + {"M4 malformed", tokenA, inline(tool("records", url, reference("not-a-credential")), vaults(attachedA)), 400, notAttached("not-a-credential")}, + {"M4 unbounded", tokenA, inline(tool("records", url, reference(long)), vaults(attachedA)), 400, invalid("MCP credential_id was not found in an attached vault")}, + {"M4 unprintable", tokenA, inline(tool("records", url, reference("bad\x01id")), vaults(attachedA)), 400, invalid("MCP credential_id was not found in an attached vault")}, + {"M4 saved reference", tokenA, `{"agent_id":"` + referenced + `","environment":{"type":"none"},"input":"Select credentials."` + vaults(secondA) + `}`, 400, notAttached(selectedA)}, + {"M5 destination", tokenA, inline(tool("records", url, reference(otherDestinationA)), vaults(attachedA)), 400, invalid("MCP credential_id " + otherDestinationA + " does not match server_url " + url)}, + {"M5 unbounded URL", tokenA, inline(tool("records", longURL, reference(otherDestinationA)), vaults(attachedA)), 400, invalid("MCP credential_id " + otherDestinationA + " does not match server_url")}, + {"M6 ambiguous", tokenA, inline(tool("records", url, ""), vaults(attachedA, secondA)), 409, failure("conflict_error", "multiple attached vault credentials match MCP server_url "+url+"; specify credential_id")}, + {"M6 ambiguous stream", tokenA, strings.TrimSuffix(inline(tool("records", url, ""), vaults(attachedA, secondA)), "}") + `,"stream":true}`, 409, failure("conflict_error", "multiple attached vault credentials match MCP server_url "+url+"; specify credential_id")}, + {"M7 unknown Vault", tokenA, inline(tool("records", url, ""), vaults(uuid.NewString())), 404, missingVault}, + {"M7 foreign Vault", tokenA, inline(tool("records", url, reference(selectedA)), vaults(attachedA, vaultB)), 404, missingVault}, + {"M8 input first", tokenA, `{"agent":{"model":"selection-model","tools":[` + tool("records", url, reference(credentialB)) + `]},"environment":{"type":"none"}` + vaults(attachedA) + `}`, 400, invalid("conversation-only sessions currently require initial input")}, + {"M8 stream", tokenA, strings.TrimSuffix(inline(tool("records", url, reference(credentialB)), vaults(attachedA)), "}") + `,"stream":true}`, 400, notAttached(credentialB)}, + } { + got := send(tc.token, http.MethodPost, "/v1/agents/sessions", tc.body, "") + if got.status != tc.status || got.body != tc.want || got.header.Get("Content-Type") != "application/json" || strings.Contains(got.body, canary) { + t.Errorf("%s: %d %s", tc.name, got.status, got.body) + } + } + // M8: the inline configuration protocol check still precedes selection. + if got := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, reference(credentialB))+`,{"type":"bogus_tool"}`, vaults(attachedA)), ""); got.status != http.StatusBadRequest || !strings.Contains(got.body, `"param":"agent.tools[1].type"`) { + t.Error("protocol error order", got.status, got.body) + } + if after := databaseDigest(t, pool); !mapsEqual(before, after) { + t.Fatal("rejected credential selection changed persisted state") + } + + // M4: missing, foreign-tenant and unattached references are byte-identical, + // including headers, for one credential ID. + foreign := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, reference(credentialB)), vaults(attachedA)), "") + unattached := send(tokenB, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, reference(credentialB)), vaults(otherVaultB)), "") + if got := send(tokenB, http.MethodDelete, "/v1/vaults/"+vaultB+"/credentials/"+credentialB, "", ""); got.status != http.StatusOK { + t.Fatal("delete B credential", got.status, got.body) + } + missing := send(tokenB, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, reference(credentialB)), vaults(vaultB)), "") + for _, got := range []selectionResponse{unattached, missing} { + if got.status != foreign.status || got.body != foreign.body || !reflect.DeepEqual(got.header, foreign.header) { + t.Fatalf("M4 responses differ: %d %v %s; want %d %v %s", got.status, got.header, got.body, foreign.status, foreign.header, foreign.body) + } + } + + // M2: the implicitly selected credential is projected in snapshots, reads, + // lists and events; anonymous tools stay null and explicit IDs are echoed. + implicit := inline(tool("records", url, "")+","+tool("open", openURL, ""), vaults(attachedA)) + expectTools := func(context string, tools []map[string]any, first any) { + t.Helper() + if len(tools) != 2 || tools[0]["credential_id"] != first || tools[1]["credential_id"] != nil || tools[0]["connection_origin"] != "service" { + t.Fatalf("%s: unexpected projection %v", context, tools) + } + } + stream := openStream(t, server, tokenA, http.MethodPost, "/v1/agents/sessions", strings.TrimSuffix(implicit, "}")+`,"stream":true}`, "selection-stream") + defer stream.stop() + snapshot := func(lines sseLines, event string) (string, []map[string]any) { + t.Helper() + for line := lines.next(t); line != "event: "+event; line = lines.next(t) { + } + data, ok := strings.CutPrefix(lines.next(t), "data: ") + var value struct { + Session struct { + ID string + Agent struct{ Tools []map[string]any } + } + } + if !ok || json.Unmarshal([]byte(data), &value) != nil || strings.Contains(data, canary) || strings.Contains(data, "mcp_credentials") { + t.Fatal("invalid event", event, data) + } + return value.Session.ID, value.Session.Agent.Tools + } + streamed, tools := snapshot(stream, "agent.session.created") + expectTools("created event", tools, selectedA) + live := openStream(t, server, tokenA, http.MethodGet, "/v1/agents/sessions/"+streamed+"/events", "", "") + defer live.stop() + if line := live.next(t); line != ": connected" { + t.Fatal(line) + } + var turn string + if err := pool.QueryRow(t.Context(), "SELECT id FROM turns WHERE session_id=$1", streamed).Scan(&turn); err != nil { + t.Fatal(err) + } + if _, err := s.TransitionTurn(t.Context(), tenantA, streamed, turn, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnCancelled}); err != nil { + t.Fatal(err) + } + _, tools = snapshot(stream, "agent.session.idle") + expectTools("idle creation event", tools, selectedA) + _, tools = snapshot(live, "agent.session.idle") + expectTools("idle live event", tools, selectedA) + if got := send(tokenA, http.MethodPost, "/v1/agents/sessions/"+streamed+"/events", `{"events":[{"type":"agent.session.input.message","input":[{"role":"user","content":[{"type":"input_text","text":"Again."}]}]}]}`, ""); got.status != http.StatusAccepted { + t.Fatal("second input", got.status, got.body) + } + _, tools = snapshot(live, "agent.session.in_progress") + expectTools("in_progress live event", tools, selectedA) + + created := send(tokenA, http.MethodPost, "/v1/agents/sessions", implicit, "selection-json") + if created.status != http.StatusCreated { + t.Fatal("implicit selection", created.status, created.body) + } + session := idOf(created.body) + expectTools("created", sessionTools(created.body), selectedA) + // The stored caller intent stays null; the private binding keeps the selection. + var storedNull bool + var binding string + if err := pool.QueryRow(t.Context(), "SELECT configuration->'agent'->'tools'->0->'credential_id' = 'null'::jsonb, configuration->'mcp_credentials'->0->>'credential_id' FROM sessions WHERE id=$1", session).Scan(&storedNull, &binding); err != nil || !storedNull || binding != selectedA { + t.Fatal("stored caller intent or private binding changed", storedNull, binding, err) + } + upper := strings.ToUpper(selectedA) + explicit := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, reference(upper))+","+tool("open", openURL, ""), vaults(attachedA)), "") + if explicit.status != http.StatusCreated { + t.Fatal("explicit selection", explicit.status, explicit.body) + } + expectTools("explicit", sessionTools(explicit.body), upper) + anonymous := send(tokenA, http.MethodPost, "/v1/agents/sessions", inline(tool("records", url, "")+","+tool("open", openURL, ""), ""), "") + if anonymous.status != http.StatusCreated { + t.Fatal("anonymous", anonymous.status, anonymous.body) + } + expectTools("without attachments", sessionTools(anonymous.body), nil) + + reads := func(context string) { + t.Helper() + for _, id := range []string{session, streamed} { + got := send(tokenA, http.MethodGet, "/v1/agents/sessions/"+id, "", "") + if got.status != http.StatusOK { + t.Fatal(context, got.status, got.body) + } + expectTools(context+" retrieve", sessionTools(got.body), selectedA) + } + list := send(tokenA, http.MethodGet, "/v1/agents/sessions?limit=100", "", "") + var page struct { + Data []json.RawMessage + } + if list.status != http.StatusOK || json.Unmarshal([]byte(list.body), &page) != nil || strings.Contains(list.body, canary) || strings.Contains(list.body, "mcp_credentials") { + t.Fatal(context, "list", list.status) + } + found := 0 + for _, item := range page.Data { + if id := idOf(string(item)); id == session || id == streamed { + expectTools(context+" list", sessionTools(string(item)), selectedA) + found++ + } + } + if found != 2 { + t.Fatal(context, "list omitted Sessions") + } + if got := send(tokenB, http.MethodGet, "/v1/agents/sessions/"+session, "", ""); got.status != http.StatusNotFound || strings.Contains(got.body, selectedA) { + t.Fatal("tenant B read tenant A's Session", got.status, got.body) + } + } + reads("before deletion") + if got := send(tokenA, http.MethodDelete, "/v1/vaults/"+attachedA+"/credentials/"+selectedA, "", ""); got.status != http.StatusOK { + t.Fatal("delete selected credential", got.status, got.body) + } + reads("after deletion") + // Same-key retries recover the original Session and projection. + retried := send(tokenA, http.MethodPost, "/v1/agents/sessions", implicit, "selection-json") + if retried.status != http.StatusCreated || idOf(retried.body) != session { + t.Fatal("retry after deletion", retried.status, retried.body) + } + expectTools("retry", sessionTools(retried.body), selectedA) + // A new creation cannot select the deleted credential and stays anonymous. + fresh := send(tokenA, http.MethodPost, "/v1/agents/sessions", implicit, "") + if fresh.status != http.StatusCreated { + t.Fatal("fresh creation", fresh.status, fresh.body) + } + expectTools("fresh", sessionTools(fresh.body), nil) +} diff --git a/services/agents-api/internal/store/mcp_credentials.go b/services/agents-api/internal/store/mcp_credentials.go index d5e425396..4dbe48b54 100644 --- a/services/agents-api/internal/store/mcp_credentials.go +++ b/services/agents-api/internal/store/mcp_credentials.go @@ -6,6 +6,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/echotext" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -26,6 +27,44 @@ type MCPCredentialBinding struct { AuthType string `json:"auth_type,omitempty"` } +// MCPCredentialSelectionError rejects a Session MCP credential selection with +// the observed official message (MV-03); the API reports a Conflict as 409 +// conflict_error and any other as 400 invalid_request_error. Selection searches +// only the attached Vaults, which the caller owns, so a missing, foreign-tenant, +// unattached or malformed reference produces the same error, and only a +// credential of an attached Vault can report a server_url mismatch. +type MCPCredentialSelectionError struct { + Conflict bool + Message string +} + +func (e *MCPCredentialSelectionError) Error() string { return e.Message } + +// echoed repeats a caller-supplied value in a selection message only within +// the shared bound; otherwise the message leaves it out. +func echoed(value string) string { + if !echotext.Allowed(value) { + return "" + } + return " " + value +} + +func mcpCredentialRequiresVault() error { + return &MCPCredentialSelectionError{Message: "MCP credential_id requires an attached vault"} +} + +func mcpCredentialNotAttached(id string) error { + return &MCPCredentialSelectionError{Message: "MCP credential_id" + echoed(id) + " was not found in an attached vault"} +} + +func mcpCredentialURLMismatch(id, url string) error { + return &MCPCredentialSelectionError{Message: "MCP credential_id" + echoed(id) + " does not match server_url" + echoed(url)} +} + +func mcpCredentialAmbiguous(url string) error { + return &MCPCredentialSelectionError{Conflict: true, Message: "multiple attached vault credentials match MCP server_url" + echoed(url) + "; specify credential_id"} +} + func attachedVaultIDs(ids []string) ([]pgtype.UUID, error) { result := make([]pgtype.UUID, 0, len(ids)) seen := map[pgtype.UUID]bool{} @@ -67,9 +106,12 @@ func (s *Store) ResolveMCPCredentials(ctx context.Context, tenantID string, vaul } var id pgtype.UUID if request.CredentialID != nil { + if len(vaults) == 0 { + return nil, mcpCredentialRequiresVault() + } id, err = parseID(*request.CredentialID) if err != nil { - return nil, ErrNotFound + return nil, mcpCredentialNotAttached(*request.CredentialID) } } rows, err := s.queries.FindMCPCredentials(ctx, sqlc.FindMCPCredentialsParams{ @@ -78,11 +120,16 @@ func (s *Store) ResolveMCPCredentials(ctx context.Context, tenantID string, vaul if err != nil { return nil, errors.New("cannot resolve MCP credential") } - if len(rows) == 0 && request.CredentialID != nil { - return nil, ErrNotFound + if request.CredentialID != nil { + if len(rows) == 0 { + return nil, mcpCredentialNotAttached(*request.CredentialID) + } + if rows[0].McpServerUrl != request.ServerURL { + return nil, mcpCredentialURLMismatch(*request.CredentialID, request.ServerURL) + } } if len(rows) > 1 { - return nil, ErrInvalidInput + return nil, mcpCredentialAmbiguous(request.ServerURL) } binding := MCPCredentialBinding{ServerLabel: request.ServerLabel, ServerURL: request.ServerURL} if len(rows) == 1 { diff --git a/services/agents-api/internal/store/mcp_credentials_test.go b/services/agents-api/internal/store/mcp_credentials_test.go index 4d1328642..bf4862f1a 100644 --- a/services/agents-api/internal/store/mcp_credentials_test.go +++ b/services/agents-api/internal/store/mcp_credentials_test.go @@ -54,28 +54,37 @@ func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { t.Fatal("private binding contains secret material") } second := create(vaults[1]) - if _, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests); !errors.Is(err, ErrInvalidInput) { - t.Fatal("ambiguous selection was admitted") + if _, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests); !isSelectionError(err, true, "multiple attached vault credentials match MCP server_url "+destination+"; specify credential_id") { + t.Fatal("ambiguous selection was admitted", err) } requests[0].CredentialID = &second.ID explicit, err := public.ResolveMCPCredentials(t.Context(), tenant, attached, requests) if err != nil || explicit[0].CredentialID != second.ID || requests[1].CredentialID != nil { t.Fatal("explicit selection did not disambiguate", err) } + notAttached := func(id string) string { return "MCP credential_id " + id + " was not found in an attached vault" } for _, tc := range []struct { owner string vaults []string id, url string + message string // Empty for the unchanged Vault 404. }{ - {tenant, attached, foreignCredential.ID, destination}, {foreign, attached, first.ID, destination}, - {tenant, []string{vaults[1].ID}, first.ID, destination}, {tenant, attached, first.ID, destination + "/other"}, - {tenant, []string{vaults[0].ID, vaults[2].ID}, first.ID, destination}, {tenant, []string{uuid.NewString()}, first.ID, destination}, + {tenant, attached, foreignCredential.ID, destination, notAttached(foreignCredential.ID)}, + {tenant, []string{vaults[1].ID}, first.ID, destination, notAttached(first.ID)}, + {tenant, attached, "not-a-credential", destination, notAttached("not-a-credential")}, + {tenant, attached, first.ID, destination + "/other", "MCP credential_id " + first.ID + " does not match server_url " + destination + "/other"}, + {foreign, attached, first.ID, destination, ""}, + {tenant, []string{vaults[0].ID, vaults[2].ID}, first.ID, destination, ""}, + {tenant, []string{uuid.NewString()}, first.ID, destination, ""}, } { _, err := public.ResolveMCPCredentials(t.Context(), tc.owner, tc.vaults, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: tc.url, CredentialID: &tc.id}}) - if !errors.Is(err, ErrNotFound) { - t.Fatal("unowned, unattached or wrong-destination selection was admitted") + if tc.message == "" && !errors.Is(err, ErrNotFound) || tc.message != "" && !isSelectionError(err, false, tc.message) { + t.Fatal("unowned, unattached or wrong-destination selection was admitted", err) } } + if _, err := public.ResolveMCPCredentials(t.Context(), tenant, nil, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: destination, CredentialID: &first.ID}}); !isSelectionError(err, false, "MCP credential_id requires an attached vault") { + t.Fatal("a reference without attachments was admitted", err) + } pool.Close() public, pool = testStore(t) cipher, _ = credentialcrypto.New(bytes.Clone(key)) @@ -122,3 +131,8 @@ func TestMCPCredentialSelectionAndScopedDecryption(t *testing.T) { t.Fatal("safe metadata lookup depended on ciphertext", err) } } + +func isSelectionError(err error, conflict bool, message string) bool { + var selection *MCPCredentialSelectionError + return errors.As(err, &selection) && selection.Conflict == conflict && selection.Message == message +} diff --git a/services/agents-api/internal/store/remote_mcp_credentials_test.go b/services/agents-api/internal/store/remote_mcp_credentials_test.go index 47f699078..86e4737e1 100644 --- a/services/agents-api/internal/store/remote_mcp_credentials_test.go +++ b/services/agents-api/internal/store/remote_mcp_credentials_test.go @@ -14,7 +14,6 @@ func TestSelfHostedServiceMCPRejectionDoesNotRequireCredentialDecryption(t *test for _, mode := range []string{"missing key", "deleted", "tampered"} { t.Run(mode, func(t *testing.T) { s, pool, tenant, vault, credential := selfHostedMCPAdmissionFixture(t) - expected := http.StatusBadRequest switch mode { case "missing key": s = store.New(pool) @@ -22,7 +21,6 @@ func TestSelfHostedServiceMCPRejectionDoesNotRequireCredentialDecryption(t *test if _, err := s.DeleteCredential(t.Context(), tenant, vault.ID, credential.ID); err != nil { t.Fatal(err) } - expected = http.StatusNotFound case "tampered": if _, err := pool.Exec(t.Context(), "UPDATE vault_credentials SET token_ciphertext=set_byte(token_ciphertext,15,get_byte(token_ciphertext,15) # 1) WHERE id=$1", credential.ID); err != nil { t.Fatal(err) @@ -50,10 +48,14 @@ func TestSelfHostedServiceMCPRejectionDoesNotRequireCredentialDecryption(t *test request.Header.Set("OpenAI-Beta", "agents=v1") response := httptest.NewRecorder() handler.ServeHTTP(response, request) - if response.Code != expected || strings.Contains(response.Body.String(), "synthetic-token") || strings.Contains(response.Body.String(), "ciphertext") || strings.Contains(response.Body.String(), "mcp_credentials") { + if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), "synthetic-token") || strings.Contains(response.Body.String(), "ciphertext") || strings.Contains(response.Body.String(), "mcp_credentials") { t.Fatal("rejected MCP credential combination admitted or disclosed", response.Code, response.Body) } - if expected == http.StatusBadRequest && !strings.Contains(response.Body.String(), "environment:none") { + message := "environment:none" + if mode == "deleted" { + message = "MCP credential_id " + credential.ID + " was not found in an attached vault" + } + if !strings.Contains(response.Body.String(), message) { t.Fatal("unsupported placement attempted credential decryption", response.Body) } assertSelfHostedMCPRejectionHasNoWrites(t, pool, tenant) diff --git a/services/agents-api/internal/store/remote_mcp_test.go b/services/agents-api/internal/store/remote_mcp_test.go index 99631531d..740decca7 100644 --- a/services/agents-api/internal/store/remote_mcp_test.go +++ b/services/agents-api/internal/store/remote_mcp_test.go @@ -47,16 +47,21 @@ func TestSelfHostedServiceMCPRejectedWithoutWrites(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) - expected := http.StatusNotFound - if mode == "anonymous" || mode == "implicit" || mode == "explicit" || strings.HasPrefix(mode, "required") { - expected = http.StatusBadRequest + // Credential selection errors (MV-03) precede the placement rejection. + expected, message := http.StatusBadRequest, "environment:none" + switch mode { + case "unattached": + message = "MCP credential_id requires an attached vault" + case "missing": + message = "MCP credential_id " + tool["credential_id"].(string) + " was not found in an attached vault" + case "wrong URL": + message = "MCP credential_id " + credential.ID + " does not match server_url https://other.example/mcp" + case "foreign Vault": + expected, message = http.StatusNotFound, "Resource not found." } - if response.Code != expected { + if response.Code != expected || !strings.Contains(response.Body.String(), message) { t.Fatal("self-hosted service MCP admitted or wrong error", mode, response.Code, response.Body) } - if expected == http.StatusBadRequest && !strings.Contains(response.Body.String(), "environment:none") { - t.Fatal("request rejected outside the service MCP placement boundary", response.Body) - } if strings.Contains(response.Body.String(), "synthetic-token") || strings.Contains(response.Body.String(), "ciphertext") || strings.Contains(response.Body.String(), "mcp_credentials") { t.Fatal("rejected request exposed private authentication") } diff --git a/services/agents-api/internal/store/vault_credentials_delete_test.go b/services/agents-api/internal/store/vault_credentials_delete_test.go index b7cb76b59..226cf1e56 100644 --- a/services/agents-api/internal/store/vault_credentials_delete_test.go +++ b/services/agents-api/internal/store/vault_credentials_delete_test.go @@ -107,8 +107,8 @@ func TestCredentialDeletionScopeBindingAndRestart(t *testing.T) { if _, err := s.MCPBearerToken(t.Context(), tenant, attached, selected[0]); !errors.Is(err, ErrNotFound) { t.Fatal("frozen selection fell back to another token") } - if _, err := s.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: original.MCPServerURL, CredentialID: &original.ID}}); !errors.Is(err, ErrNotFound) { - t.Fatal("deleted explicit selection was admitted") + if _, err := s.ResolveMCPCredentials(t.Context(), tenant, attached, []MCPCredentialRequest{{ServerLabel: "tools", ServerURL: original.MCPServerURL, CredentialID: &original.ID}}); !isSelectionError(err, false, "MCP credential_id "+original.ID+" was not found in an attached vault") { + t.Fatal("deleted explicit selection was admitted", err) } page, err := public.ListCredentials(t.Context(), tenant, vault.ID, "", 100, true, []string{"active", "archived"}) if err != nil || len(page.Credentials) != 1 || !reflect.DeepEqual(page.Credentials[0], sibling) { diff --git a/services/agents-api/internal/store/vault_credentials_oauth_test.go b/services/agents-api/internal/store/vault_credentials_oauth_test.go index 7c2d2b172..b74b3fd30 100644 --- a/services/agents-api/internal/store/vault_credentials_oauth_test.go +++ b/services/agents-api/internal/store/vault_credentials_oauth_test.go @@ -237,8 +237,8 @@ func TestOAuthCredentialSelectionIncludesBothAuthTypes(t *testing.T) { if _, err := s.CreateStaticCredential(t.Context(), tenant, vault.ID, CreateStaticCredentialInput{Name: "Static", MCPServerURL: input.MCPServerURL, Token: "static"}); err != nil { t.Fatal(err) } - if _, err := s.ResolveMCPCredentials(t.Context(), tenant, []string{vault.ID}, requests); !errors.Is(err, ErrInvalidInput) { - t.Fatal("ambiguous mixed credentials selected") + if _, err := s.ResolveMCPCredentials(t.Context(), tenant, []string{vault.ID}, requests); !isSelectionError(err, true, "multiple attached vault credentials match MCP server_url "+input.MCPServerURL+"; specify credential_id") { + t.Fatal("ambiguous mixed credentials selected", err) } requests[0].CredentialID = &credential.ID if selected, err := s.ResolveMCPCredentials(t.Context(), tenant, []string{vault.ID}, requests); err != nil || selected[0] != bindings[0] { diff --git a/services/agents-api/tests/official_mcp_credentials.py b/services/agents-api/tests/official_mcp_credentials.py index 9d9172978..489306f07 100644 --- a/services/agents-api/tests/official_mcp_credentials.py +++ b/services/agents-api/tests/official_mcp_credentials.py @@ -41,7 +41,10 @@ def credential(api, vault, destination, name): def verify_session(value, expected_ids, expected_credential): body = value.to_dict() assert body["vault_ids"] == expected_ids + # The first tool projects its explicit or implicitly selected credential + # (MV-02); the anonymous tool stays null. assert body["agent"]["tools"][0]["credential_id"] == expected_credential + assert all(item["credential_id"] is None for item in body["agent"]["tools"][1:]) assert "headers" not in body["agent"]["tools"][0]["transport"] assert canary not in json.dumps(body) and "mcp_credentials" not in body assert value.status == "in_progress" @@ -52,16 +55,17 @@ def verify_session(value, expected_ids, expected_credential): expect_error(NotFoundError, lambda: other.beta.agents.sessions.retrieve(value.id)) saved_sessions.append(value) - # Omission and null retain the declared public field while resolving the same - # unique private selection; explicit selection is preserved publicly. - for declaration in (tool, {**tool, "credential_id": None}, {**tool, "credential_id": chosen.id}): + # Omission and null project the unique selection; explicit selection is + # echoed. The minimal tool omits connection_origin (MV-01). + minimal = {key: item for key, item in tool.items() if key != "connection_origin"} + for declaration in (tool, {**tool, "credential_id": None}, {**tool, "credential_id": chosen.id}, minimal): request = deepcopy(inline) request["agent"]["tools"][0] = declaration key = {"Idempotency-Key": "mcp-vault-" + str(uuid.uuid4())} response = sessions.with_raw_response.create(**request, extra_headers=key) value = response.parse() assert response.http_response.json() == value.to_dict() - verify_session(value, ids, declaration.get("credential_id")) + verify_session(value, ids, chosen.id) retries.append((request, key, value)) saved = client.beta.agents.create(model="requested-model", tools=[tool, anonymous]) @@ -69,7 +73,7 @@ def verify_session(value, expected_ids, expected_credential): saved_key = {"Idempotency-Key": "mcp-vault-saved-" + saved.id} value = sessions.create(**saved_spec, extra_headers=saved_key) saved_session = value - verify_session(value, ids, None) + verify_session(value, ids, chosen.id) retries.append((saved_spec, saved_key, value)) assert value.agent.id == saved.id @@ -86,9 +90,10 @@ def verify_session(value, expected_ids, expected_credential): event = event_data(response.iter_lines()) assert event["type"] == "agent.session.created" assert canary not in json.dumps(event) and "mcp_credentials" not in event["session"] + assert event["session"]["agent"]["tools"][0]["credential_id"] == chosen.id streamed = sessions.retrieve(event["session"]["id"]) assert streamed.id == event["session"]["id"] and streamed.agent.to_dict() == event["session"]["agent"] - verify_session(streamed, ids, None) + verify_session(streamed, ids, chosen.id) retries.append((inline, stream_key, streamed)) # Vault defaults retain anonymous MCP and never implicitly search other @@ -102,7 +107,7 @@ def verify_session(value, expected_ids, expected_credential): # Same-project users can attach the same Vaults; role names do not add a # product-specific approval or permission boundary to the independent API. value = peer.beta.agents.sessions.create(**inline) - verify_session(value, ids, None) + verify_session(value, ids, chosen.id) second = credential(client, attached[1], url, "Second matching credential") explicit = deepcopy(inline) @@ -112,7 +117,9 @@ def verify_session(value, expected_ids, expected_credential): for request, key, original in retries: assert sessions.create(**request, extra_headers=key) == original assert len(list(sessions.turns.list(original.id))) == 1 - expect_error(BadRequestError, lambda: sessions.create(**inline)) + error = expect_error(ConflictError, lambda: sessions.create(**inline)) + assert error.body == {"type": "conflict_error", "code": "conflict_error", "param": None, + "message": "multiple attached vault credentials match MCP server_url " + url + "; specify credential_id"} changed = client.beta.agents.update(saved.id, tools=[]) saved_agents.append(changed) @@ -123,29 +130,57 @@ def verify_session(value, expected_ids, expected_credential): # Saved schemas can retain references without obtaining execution access. referenced = client.beta.agents.create(model="requested-model", tools=[{**tool, "credential_id": outside.id}]) saved_agents.append(referenced) - expect_error(NotFoundError, lambda: sessions.create(agent_id=referenced.id, + expect_error(BadRequestError, lambda: sessions.create(agent_id=referenced.id, input="Verify mcp credentials fixture admission.", environment={"type": "none"}, vault_ids=ids)) before = {item.id for item in sessions.list()} foreign_before = {item.id for item in other.beta.agents.sessions.list()} + # Selection errors (MV-03) use the official fields; missing, foreign and + # unattached references share one message. + def selection(message): + return {"type": "invalid_request_error", "code": "invalid_request_error", "param": None, "message": message} + + def not_attached(reference): + return selection("MCP credential_id " + reference + " was not found in an attached vault") + rejected = [] - for reference in (chosen.id, outside.id, foreign_credential.id, alternate.id, str(uuid.uuid4())): + missing = str(uuid.uuid4()) + for reference, expected in ((chosen.id, not_attached(chosen.id)), (outside.id, not_attached(outside.id)), + (foreign_credential.id, not_attached(foreign_credential.id)), + (missing, not_attached(missing)), ("not-a-credential", not_attached("not-a-credential")), + (alternate.id, selection("MCP credential_id " + alternate.id + " does not match server_url " + url))): request = deepcopy(inline) request["agent"]["tools"][0]["credential_id"] = reference if reference == chosen.id: request["vault_ids"] = [attached[1].id] - rejected.append((request, 404)) - rejected.extend([({**explicit, "vault_ids": [foreign.id]}, 404), - ({**explicit, "vault_ids": [str(uuid.uuid4())]}, 404), - (inline, 400)]) + rejected.append((request, 400, expected)) + request = deepcopy(inline) + request["agent"]["tools"][0]["credential_id"] = chosen.id + for vault_ids in ({}, {"vault_ids": None}, {"vault_ids": []}): + unattached_request = {key: item for key, item in request.items() if key != "vault_ids"} + rejected.append(({**unattached_request, **vault_ids}, 400, selection("MCP credential_id requires an attached vault"))) + rejected.extend([({**explicit, "vault_ids": [foreign.id]}, 404, None), + ({**explicit, "vault_ids": [str(uuid.uuid4())]}, 404, None), + (inline, 409, {"type": "conflict_error", "code": "conflict_error", "param": None, + "message": "multiple attached vault credentials match MCP server_url " + url + "; specify credential_id"})]) for invalid in ("invalid", 3, [None], [3], {}): - rejected.append(({**explicit, "vault_ids": invalid}, 400)) - for request, status in rejected: + rejected.append(({**explicit, "vault_ids": invalid}, 400, None)) + for request, status, expected in rejected: for initial in ({}, {"input": "Must not be admitted", "stream": True}): response = raw.post(base + "/agents/sessions", headers=headers, json={**request, **initial}) assert response.status_code == status assert response.headers["content-type"].startswith("application/json") assert canary not in response.text and foreign.id not in response.text + assert expected is None or response.json()["error"] == expected + # The same reference from another tenant is byte-identical to the unattached case. + foreign_request = deepcopy(inline) + foreign_request["agent"]["tools"][0]["credential_id"] = chosen.id + foreign_request["vault_ids"] = [foreign.id] + other_headers = {"Authorization": "Bearer " + other.api_key, "OpenAI-Beta": "agents=v1"} + unattached_response = raw.post(base + "/agents/sessions", headers=headers, json=rejected[0][0]) + foreign_response = raw.post(base + "/agents/sessions", headers=other_headers, json=foreign_request) + assert foreign_response.status_code == unattached_response.status_code == 400 + assert foreign_response.content == unattached_response.content assert {item.id for item in sessions.list()} == before assert {item.id for item in other.beta.agents.sessions.list()} == foreign_before for field in ({"vault_ids": [attached[0].id]}, @@ -155,7 +190,7 @@ def verify_session(value, expected_ids, expected_credential): public = raw.get(base + "/agents/sessions", headers=headers).json() assert canary not in json.dumps(public) and "mcp_credentials" not in json.dumps(public) - print("Public MCP Vault admission: explicit/unique selection, owner/destination isolation, safe snapshots, creation streams and stable retries passed; native execution is checked separately.") + print("Public MCP Vault admission: explicit/unique selection and its projection, official selection errors, owner/destination isolation, safe snapshots, creation streams and stable retries passed; native execution is checked separately.") return saved_sessions, saved_agents, retries From 5ccab5fc7310c29606befcd845dc374cc4c6d6ec Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 20:34:49 +0000 Subject: [PATCH 3/5] Document MCP origin defaults and credential selection Update the handler annotations, OpenAPI, CONTRIBUTING, the service README, credentials guide, contract README, the dated alignment section and the operation evidence register (MV). --- CONTRIBUTING.md | 26 +++++--- contracts/agents-api/README.md | 12 ++-- .../official-semantics-alignment.md | 61 +++++++++++++++++++ contracts/agents-api/openapi.yaml | 45 ++++++++------ contracts/agents-api/operation-evidence.md | 15 ++--- services/agents-api/README.md | 13 ++-- services/agents-api/credentials.md | 28 +++++++-- services/agents-api/internal/api/agents.go | 2 +- services/agents-api/internal/api/handler.go | 2 +- 9 files changed, 152 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f96d6aaea..c723330b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1984,8 +1984,10 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti snapshots; per-Session tools replace the whole field. The initial profile admits HTTP(S), boolean `required` (default false), empty/null metadata and empty/null headers. Static and OAuth bearer authentication require HTTPS and the attached-Vault rules below. - Inline authorization, URL userinfo/query/fragment, - other origins and stdio remain explicitly unsupported. + An omitted/null `connection_origin` on HTTP transport is stored as `service` + before the other checks, identical to an explicit declaration, as observed + officially. Inline authorization, URL userinfo/query/fragment, the `environment` + origin and stdio remain explicitly unsupported. - Codex required MCP initialization additionally needs `mcp_http_required`, advertised only for the verified native pin and checked during selection, final preclaim and daemon dispatch/preparation. Preserve the boolean through typed messages, @@ -2025,22 +2027,28 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti storage. Core-managed OAuth uses this same access-token path; native OAuth login/refresh and hosted redirect/error equivalence remain separate work. - Session `vault_ids` omission/null/empty means `[]`; nonempty attachments must all - belong to the authenticated tenant. Preserve caller order and public MCP + belong to the authenticated tenant. Preserve caller order and the stored caller `credential_id`. Saved Agents may store a nullable/nonempty credential reference without authorizing its use. Session admission resolves an explicit credential only inside attached Vaults for the exact declared URL, or selects the unique matching static or OAuth credential when the ID is omitted/null. No match remains - anonymous; ambiguity is a local 400 and unavailable references use the same 404. - Resolve before any Session, initial input or event write. Freeze safe bindings, - including anonymous decisions, in private Session configuration; never populate - the public credential field from implicit resolution. At actual dispatch, recheck + anonymous. Selection errors use the observed official messages with a null param: + a reference without attachments, one outside the attached Vaults and one for + another URL are 400 `invalid_request_error`; several implicit matches are 409 + `conflict_error`. Missing, foreign-tenant, unattached and malformed references + share one message; echo caller values only within `internal/echotext`. Unknown + or foreign Vaults keep the same 404. Resolve after the input requirement and + before any Session, initial input or event write. Freeze safe bindings, + including anonymous decisions, in private Session configuration. Session + projections, never the stored configuration, show an implicitly selected ID in a + null/omitted public `credential_id`, also after deletion. At actual dispatch, recheck tenant, attached Vault, selected ID, frozen auth type and exact URL before scoped decryption. Metadata queries select no ciphertext; tokens enter only the existing transient daemon request. Selected authentication requires `mcp_http_bearer_auth` during device selection and the final preclaim check. Missing/wrong keys or binding failures never fall back to anonymous execution. Exact URL equality, - immutable selection timing, implicit response population and hosted error/redirect - semantics remain local decisions or unverified gaps. No new MCP loop is permitted. + immutable selection timing and hosted redirect semantics remain local decisions + or unverified gaps. No new MCP loop is permitted. - Saved Agent execution defaults use separate input and safe-output Core extensions. Keep model-provider bundles whole at every replacement boundary: endpoint, key, protocol and limits must never be independently inherited. Ordinary Agent JSON diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 9b1561eba..f77757cfd 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -337,7 +337,8 @@ upgrade the protocol. stays unresolved rather than being populated from a guessed model default. An explicit effort/summary is retained. Omitted/null service tier currently follows the service's `auto` policy; complete upstream-default/error/retry conformance is - unverified. HTTP MCP with explicit `service` origin and + unverified. HTTP MCP with `service` origin (omitted or null on HTTP transport is + saved as `service`) and boolean `required` (default false) supports saved configuration and Codex `none` execution, with Claude SDK also supporting its qualified `none` subset. V1 `self_hosted` explicitly rejects @@ -351,11 +352,14 @@ upgrade the protocol. headers. Omitted/null `allowed_tools` is unrestricted; `[]` denies all tools. Session `vault_ids` attaches tenant-owned Vaults. Explicit `credential_id` must belong to an attached Vault and match the exact HTTPS URL; omission/null selects - one matching static or OAuth credential, zero stays anonymous and ambiguity fails. Private - immutable selections do not populate the public credential field. Scope is + one matching static or OAuth credential, zero stays anonymous and ambiguity is a + 409 `conflict_error`. Selection errors use the observed official messages, with + one message for missing, foreign and unattached references. Session projections + show an implicitly selected credential ID in the public field; the immutable + stored selection and caller intent are unchanged. Scope is rechecked before dispatch-only decryption; authenticated execution requires the separate bearer capability and never downgrades on failure. Exact URL/selection - timing, implicit response population and hosted errors remain local or unverified. + timing and hosted redirect behavior remain local or unverified. Other MCP variants and enabled web-search execution remain gaps, not changes to the pinned target or claims of complete resource coverage. diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index e03d6238a..1bb4eddcb 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -664,3 +664,64 @@ The pinned-SDK scripts `official_agents.py` and `official_agent_update.py` asser the saved projections and the admission rejection, and `official_tool_policy.py` adds saved enabled search to its live rejection cases. Real Core acceptance is recorded separately by the coordinator. + +## MCP origin and credential selection — September 23 + +The minimal pinned-SDK MCP tool `{type, server_label, transport}` now works on +Core, and Session MCP credential selection projects and reports errors as the +official service does. Evidence is MV-01..03 in the campaign scan recorded +privately in `~/.parsar/remediation/20260923/campaign-scan-6/mcp-vaults/` +(`findings.json`, `REPORT.txt`, `official-ledger.jsonl`): owned Agents, three +owned Sessions and two Vaults with four static-bearer Credentials, all deleted +and read back 404. The error records are `ERR-UNATTACHED` +(`req_b687ac760c03451caa5973be8d65a3ae`), `ERR-URL-MISMATCH` +(`req_90009e0ba2e548ad88a30516faeec852`), `ERR-AMBIGUOUS` +(`req_18b4777d35844be9a5549c8e58fc8747`), `ERR-CREDENTIAL-BOGUS` +(`req_5ba08377d4a841c98849cd4649e6fcaa`) and `ERR-VAULT-BOGUS` +(`req_0274192656b549739951de213e16514a`); the origin default is +`req_4a99a59eba2445c4b9ae22e74667f2b8` and `req_108ecc7c779240528efccd5ad55eebed`. + +| Row | Case | Core behavior | +| --- | --- | --- | +| M1 | HTTP MCP tool with omitted or null `connection_origin`, on a saved Agent, an inline Session agent or a per-Session replacement | Saved and projected as `"service"`. The stored and frozen configuration equals an explicit `service` declaration, so execution is unchanged. Explicit `"environment"` and other transports keep their rejection. | +| M2 | Session tool without an explicit `credential_id` whose attached credential was selected | Retrieve, list and the created, in-progress and idle event snapshots show the selected credential ID, also after that credential is deleted. Anonymous and unmatched tools stay null; explicit IDs are echoed as sent. | +| M3 | `credential_id` with omitted, null or empty `vault_ids` | 400 `invalid_request_error`, null param: "MCP credential_id requires an attached vault". | +| M4 | `credential_id` not in an attached Vault: missing, foreign tenant, another Vault of the tenant, or malformed | 400 `invalid_request_error`, null param: "MCP credential_id `` was not found in an attached vault". Byte-identical for one ID across the missing, foreign and unattached cases. | +| M5 | Credential in an attached Vault for another URL | 400 `invalid_request_error`, null param: "MCP credential_id `` does not match server_url ``". | +| M6 | Several attached credentials match implicitly | 409 `conflict_error`, null param: "multiple attached vault credentials match MCP server_url ``; specify credential_id". | +| M7 | Unknown or foreign Vault in `vault_ids` | Unchanged 404 `not_found_error`, "Resource not found." (the official message names the ID). | +| M8 | Order and writes | Inline agent protocol errors and the input requirement come first; selection precedes any write, and a rejection writes nothing. | +| M9 | Dispatch | Unchanged: frozen bindings, scoped recheck before decryption, fail-closed on missing keys or decryption, no anonymous fallback. | + +Decisions: + +- `` and `` are the request's values, repeated only under the shared + bounded-echo rule (`internal/echotext`: at most 256 bytes of printable UTF-8); + otherwise the message leaves the value out. +- Selection searches only attached Vaults, which must all belong to the caller. + An explicit ID is found there by ID alone, so a missing, foreign or unattached + ID yields one response, and only a credential of an attached Vault can report + a server_url mismatch. The mismatch message repeats the tool's URL, not the + credential's. +- The projection reads the frozen private binding of the tool's label and URL, + and shows it only while the binding's Vault is among the Session's + attachments. It exposes a credential ID only, never tokens or ciphertext. + Stored configuration keeps the caller's null, so creation retries, recorded + caller intent and dispatch are unchanged. Retries recover the original + projection, also after deletion; a new creation can no longer select a deleted + credential. +- A same-key retry that omits the origin recovers a Session created with the + explicit form when the request has no recorded caller intent; with recorded + intent (attached Vaults or credential references) it remains the local + `idempotency_conflict`, as for any changed request. +- A deleted, previously selected credential is still admitted at later input and + fails at dispatch (MV-04); that remains a separate batch. + +Go tests cover the origin default, the projection and its private-binding +checks, and the typed store errors. A real-PostgreSQL HTTP test with tenants A +and B covers M1–M8, the byte-identical M4 responses with headers, a +whole-database digest over every rejection, M2 across creation, retrieve, list, +creation-stream and live events, deletion and retries. The pinned-SDK scripts +`official_mcp.py` and `official_mcp_credentials.py` assert the omitted origin, +the projection and the error fields. Real Core acceptance is recorded separately +by the coordinator. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index ff61f8869..b287c9f75 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2930,15 +2930,16 @@ paths: or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search - and HTTP MCP with nullable credential_id and explicit service origin and boolean - required defaulting to false. Saving credential_id grants no access: Session - admission checks attached Vault ownership and destination. MCP allowed_tools - preserves null versus empty; saved HTTP transport includes empty headers. - Model-derived reasoning defaults, other MCP variants and public retry conformance - remain incomplete. web_search saves every pinned mode: omitted or null mode - is saved as live and omitted or null context_size as medium; allowed_domains - preserves null versus empty and a present location, including {}, includes - all four keys with null for omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, + and HTTP MCP with nullable credential_id, service origin (omitted or null + on HTTP transport is saved as service) and boolean required defaulting to + false. Saving credential_id grants no access: Session admission checks attached + Vault ownership and destination. MCP allowed_tools preserves null versus empty; + saved HTTP transport includes empty headers. Model-derived reasoning defaults, + other MCP variants and public retry conformance remain incomplete. web_search + saves every pinned mode: omitted or null mode is saved as live and omitted + or null context_size as medium; allowed_domains preserves null versus empty + and a present location, including {}, includes all four keys with null for + omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session @@ -3807,19 +3808,25 @@ paths: supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. - Codex additionally supports HTTP MCP with explicit service origin, native - allowed_tools and boolean required defaulting to false. Session vault_ids - attach only project-owned Vaults; credential_id selects an attached static - bearer credential for the exact HTTPS URL, while null/omission selects a unique - match or remains anonymous. Ambiguous selection rejects creation. Frozen private - selections never populate an omitted public credential_id; missing decryption + Codex additionally supports HTTP MCP with service origin (omitted or null + on HTTP transport is saved as service), native allowed_tools and boolean required + defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id + selects an attached static bearer or OAuth credential for the exact HTTPS + URL, while null/omission selects a unique match or remains anonymous. Session + reads, lists and event snapshots show that implicitly selected credential + ID in a null or omitted credential_id, also after the credential is deleted; + anonymous selections stay null and the stored caller intent is unchanged. + After the input requirement and before any write, a credential_id without + vault_ids, one outside the attached Vaults (one message for missing, foreign + and unattached IDs) or one for another server_url returns 400 invalid_request_error, + and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing - and error parity remain unverified. Other MCP origins and OAuth remain unsupported. - The self_hosted profile requires Codex, an absolute workspace_directory and - empty capability_directories, with optional non-deferred function tools and - HTTP MCP using explicit service origin, optionally authenticated by the attached + and error parity remain unverified. Other MCP origins and native OAuth login + remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory + and empty capability_directories, with optional non-deferred function tools + and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted diff --git a/contracts/agents-api/operation-evidence.md b/contracts/agents-api/operation-evidence.md index 2474ba43c..395ba2494 100644 --- a/contracts/agents-api/operation-evidence.md +++ b/contracts/agents-api/operation-evidence.md @@ -1,6 +1,6 @@ # Pinned operation evidence inventory — 2026-09-23 -Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, the list cursor error batch (CE) updates unresolved `after` cursor errors on every list, the input conflict batch (CF) gives every 409 type `conflict_error` and aligns Session input conflicts and tool result target errors, and the saved web_search batch (SW) saves every pinned `web_search` mode while Session admission keeps rejecting enabled search, and the workspace file write batch (FW) aligns Files.create parent creation, no-replacement and the inline size bound, and the item serialization batch (SR) aligns Item/event null fields, assistant message event framing, reasoning keys and the Session usage rule; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. +Baseline inventory of main `b5715912f09333e2b4449ec6f0eecaabce44c9b7`. The Session admission batch below updates creation and metadata validation, the list query tolerance batch (L) updates list and resource query handling, the validation error batch (X) updates field error codes/params, malformed path IDs and U+0000 handling, the Environment Files wire batch (G) updates Files.create/list status, envelope, query, path and empty-page behavior, and the creation stream settlement batch (J) updates creation SSE lifetime/snapshot, terminal Turn usage and Turn start order, and the Session deletion batch (Z) updates the deletion lifecycle, and the Agent configuration validation batch (M) updates saved and inline Agent configuration errors, the whitespace input batch (P) admits whitespace-only message text, the list cursor error batch (CE) updates unresolved `after` cursor errors on every list, the input conflict batch (CF) gives every 409 type `conflict_error` and aligns Session input conflicts and tool result target errors, and the saved web_search batch (SW) saves every pinned `web_search` mode while Session admission keeps rejecting enabled search, and the workspace file write batch (FW) aligns Files.create parent creation, no-replacement and the inline size bound, and the item serialization batch (SR) aligns Item/event null fields, assistant message event framing, reasoning keys and the Session usage rule, and the MCP credential selection batch (MV) saves an omitted HTTP MCP origin as `service`, projects the implicitly selected Session credential and aligns selection errors; historical evidence retains its original revision and scope. This inventory guides repeated qualification and does not assert complete compatibility. Baseline: `contracts/agents-api/upstream.json`, SDK **3.13.0**, upstream commit **d7c41efee1b0802b79f3f88a678ef2052b06e9ce**, `OpenAI-Beta: agents=v1`. AGENTS.md and relevant CONTRIBUTING.md compatibility, ownership and evidence rules govern this inventory. @@ -50,6 +50,7 @@ Repository paths below are relative to the inspected worktree; private evidence | CF | [Session input conflicts and result targets](official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23); private `~/.parsar/remediation/20260923/campaign-scan-4/errors/findings.json` ERR-22/27 (raw `official/results.json`: `sessB-message-while-running`, `sessA-delete-while-waiting`) and `~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` EVT-11/12/14 (raw `official/calls-s2.json`: `s2-result-unknown-call`, `s2-result-unknown-turn`, `s2-result-duplicate-after-terminal`, `s2-result-changed-after-terminal`, `s2-result-after-cancel`). Rows CF1–CF9 of that section. Go API bodies, real-PostgreSQL HTTP replay with tenant B and a no-write digest, pinned-SDK scripts; live acceptance is recorded with the batch. | | SW | [Saved web_search modes](official-semantics-alignment.md#saved-web_search-modes--september-23); private `~/.parsar/remediation/20260923/campaign-scan-3/subagents-tools/findings.json` TV-05 (W01/W02) and `~/.parsar/remediation/20260923/saved-web-search/official/{results.json,ledger.jsonl}`: four owned Agents (all deleted) with create records `type-only`, `mode-null`, `mode-cached`, `mode-cached-full`, update records `update-disabled`, `update-omitted-low`, `update-live-domains-empty` and `retrieve`, plus a second probe of two Agents (deleted, 404 confirmed) with `location-partial-omitted` and `location-empty`, without a Session or model. Rows W1–W8 of that section. Go handler, real-PostgreSQL HTTP exact-bytes/no-write/tenant B, pinned-SDK, TypeScript client and Web tests; independent acceptance is recorded with the batch. | | SR | [Item serialization](history-events-usage.md#item-serialization-2026-09-23); private `~/.parsar/remediation/20260923/campaign-scan-2/events-tools/findings.json` EVT-09, EVT-10, EVT-13 with raw frames `official/streams-s1..s4.json` and Items pages `official/calls-s2.json` `s2-items-after-t1`, `calls-s4.json` `s4-items`; `campaign-scan-1/sessions/findings.json` SES-23/25 and `campaign-scan-1/vaults-agents/findings.json` VA-11; `campaign-scan-5/sessions-turns/findings.json` ST-03 with raw `official/calls.json`; live-kit EVT-24 `creation-stream-settlement/acceptance/candidate-evidence/codex-kimi/attempt-1/r1-*.json`. Rows S1–S8 of that section. Go contract, API and real-PostgreSQL store tests, Codex adapter usage tests, the pinned-SDK official client suite and TypeScript client/Web tests; live acceptance is recorded with the batch. | +| MV | [MCP origin and credential selection](official-semantics-alignment.md#mcp-origin-and-credential-selection--september-23); private `~/.parsar/remediation/20260923/campaign-scan-6/mcp-vaults/findings.json` MV-01..03 with raw records in `official-ledger.jsonl` (`AG1-minimal-and-nulls`, `S1-T1-create-stream`, `S1-after-c1-delete-session-get`, `S1-final-session-get`, `ERR-UNATTACHED`, `ERR-URL-MISMATCH`, `ERR-AMBIGUOUS`, `ERR-CREDENTIAL-BOGUS`, `ERR-VAULT-BOGUS`): owned Agents, three owned Sessions, two Vaults and four Credentials, all deleted. Rows M1–M9 of that section. Go API/store and real-PostgreSQL tenant A/B HTTP tests with a no-write digest, pinned-SDK official client scripts; live acceptance is recorded with the batch. | ## Per-operation evidence matrix @@ -57,18 +58,18 @@ Paths in the appendix include `/v1`. SDK names here omit `client.`. `P` means pa | # | SDK operation | Implemented behavior | Official wire observation | Core validation | Known difference / unverified semantics | | --- | --- | --- | --- | --- | --- | -| 1 | beta.agents.create | P: saved configuration, 201; metadata/name errors use official code and param; configuration protocol errors use official code, JSON-path param and message; repeated tools and non-object schema roots reject; every pinned `web_search` mode is saved, omitted/null mode as `live` | R `agent-create-supported`; initial unsupported model case 400; X VA-07/08/09; M TV-01/02 `AC01`/`AC02`; SW TV-05 `type-only`, `mode-null`, `mode-cached`, `mode-cached-full` | C DB create; T saved-Agent execution references; X DB field errors and U+0000 no-write; M DB configuration errors, no-write and tenant replay; SW DB exact tool bytes on create/retrieve/list, admission rejection without writes and tenant B | Model-derived reasoning defaults and unsupported configurations; enabled `web_search` is saved but rejects at Session admission; multi-error order, unsampled kind phrases and complete default/null/errors unknown | +| 1 | beta.agents.create | P: saved configuration, 201; metadata/name errors use official code and param; configuration protocol errors use official code, JSON-path param and message; repeated tools and non-object schema roots reject; every pinned `web_search` mode is saved, omitted/null mode as `live`; omitted/null HTTP MCP `connection_origin` is saved as `service` | R `agent-create-supported`; initial unsupported model case 400; X VA-07/08/09; M TV-01/02 `AC01`/`AC02`; SW TV-05 `type-only`, `mode-null`, `mode-cached`, `mode-cached-full`; MV MV-01 `AG1-minimal-and-nulls` | C DB create; T saved-Agent execution references; X DB field errors and U+0000 no-write; M DB configuration errors, no-write and tenant replay; SW DB exact tool bytes on create/retrieve/list, admission rejection without writes and tenant B; MV DB omitted/null origin equals explicit | Model-derived reasoning defaults and unsupported configurations; enabled `web_search` is saved but rejects at Session admission; multi-error order, unsampled kind phrases and complete default/null/errors unknown | | 2 | beta.agents.retrieve | P: tenant-owned saved read | R `agent-read`, `agent-read-deleted` | C DB own/foreign/deleted read | Full field defaults and inline-vs-saved lifetime | -| 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp; metadata/name errors use official code and param; configuration errors as for create, before the Agent lookup; `web_search` projections as for create | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels; X VA-07/08/09/10; M TV-01..03 and TV-07 update labels (`F01`–`X01`, `W04`–`M02`); SW `update-disabled`, `update-omitted-low`, `update-live-domains-empty`, `retrieve` | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests; M DB owned/foreign/missing/malformed-ID replay, K1 values and isolation; SW DB update projections, retrieve/list bytes and same-key Session retry | Model-dependent default recomputation; uncommon nested/null/error variants | +| 3 | beta.agents.update | P: atomic replacements; empty body touches timestamp; metadata/name errors use official code and param; configuration errors as for create, before the Agent lookup; `web_search` and MCP origin projections as for create | R `agent-patch-metadata`, `agent-null-fields`, `agent-noop`, `agent-nested-reasoning`, rejection labels; X VA-07/08/09/10; M TV-01..03 and TV-07 update labels (`F01`–`X01`, `W04`–`M02`); SW `update-disabled`, `update-omitted-low`, `update-live-domains-empty`, `retrieve` | C DB no-op/unchanged snapshot; resource implementation-validation.md actual PostgreSQL SDK update tests; M DB owned/foreign/missing/malformed-ID replay, K1 values and isolation; SW DB update projections, retrieve/list bytes and same-key Session retry; MV DB null-origin update | Model-dependent default recomputation; uncommon nested/null/error variants | | 4 | beta.agents.list | P: scoped cursor list; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor, malformed included, is the missing 404 | R `agent-list-empty-scoped`, `agent-list-limit101`; L VA-01/02/03/04/18; CE ERR-13 `cur-agents-random`, `cur-agents-othertype-session` | L DB `official_list_query.py` tenant A/B; CE DB cursor matrix tenant A/B | Core page capacity 100; no inferred official cap. Overflowing limits unsampled | | 5 | beta.agents.delete | P: resource deletion | R `cleanup-agent`, subsequent 404 | C DB delete/post-delete | Referenced/in-flight/repeated-delete exact parity | -| 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles; inline agent configuration errors use official fields with `agent.` params before the input requirement, and saved records with repeated tools or non-object schema roots reject admission; fresh creation SSE sends the JSON 201 projection, then ends right after the first idle recorded when a Turn ends or an input reservation stops being pending, or any failed, never sending later events; nothing admitted ends after `created`; a silent settlement ends after events up to the cursor read with a settled projection in one snapshot. A same-key stream retry returns 201, sends no events and ends at once; whitespace-only text is admitted and stored verbatim, while empty text/content/input keep the local 400 | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream; J creation streams closed after idle, open through requires_action; M TV-01..04 `SC01`–`SC11`; P SES-01..03 whitespace-only string and message input 201, verbatim Item | N Live none admission and retry; C Live three hosted profiles; D/T/K/I recorded additional workflows; J DB creation-stream lifetime/snapshot/retry; M DB inline and saved-override configuration errors without writes; P DB verbatim whitespace Items and unchanged empty-input 400 without writes | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Stream retry, self-hosted, hosted and no-input stream lifetimes are local; work drained before a silent-settlement read can still be sent. Many input/tool/environment combinations restricted; harness admission limits keep the local code (TV-06). Empty-input 400s keep the local code and message; `["", text]` parts are accepted but unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6) | -| 7 | beta.agents.sessions.retrieve | P: persisted state, required actions, usage; malformed ID equals missing; `agent.reasoning` carries both keys, null when unset; usage is the recorded root Turn sum only when every root Turn has ended with known usage, otherwise null | S `retrieve-1.json`, `session-after-1.json`; H recovered state; X SES-28; SR SES-23, EVT-13, ST-03 | C Live history; T pending actions; H Core acceptance recorded; SR API/DB reasoning and usage rule | Complete statuses/actions/lifecycle timing; Claude/MiniMax public usage remains null; model-derived default effort not resolved (VA-11); queued-Turn usage unobserved (treated as not ended) | +| 6 | beta.agents.sessions.create | P: JSON/live SSE 201, saved/inline frozen config, initial messages, native profiles; inline agent configuration errors use official fields with `agent.` params before the input requirement, and saved records with repeated tools or non-object schema roots reject admission; fresh creation SSE sends the JSON 201 projection, then ends right after the first idle recorded when a Turn ends or an input reservation stops being pending, or any failed, never sending later events; nothing admitted ends after `created`; a silent settlement ends after events up to the cursor read with a settled projection in one snapshot. A same-key stream retry returns 201, sends no events and ends at once; whitespace-only text is admitted and stored verbatim, while empty text/content/input keep the local 400; omitted/null HTTP MCP origin is `service`; MCP credential selection errors use the official status, code, null param and message after the input requirement, with one message for missing, foreign and unattached references, and write nothing | S `create-1/2.json`, `omitted-input.json`, `null-input.json`, `empty-array-input.json`, `retry-status-original/repeat.json`; H stream; J creation streams closed after idle, open through requires_action; M TV-01..04 `SC01`–`SC11`; P SES-01..03 whitespace-only string and message input 201, verbatim Item; MV MV-01 `S1-T1-create-stream`, MV-03 `ERR-UNATTACHED`, `ERR-URL-MISMATCH`, `ERR-AMBIGUOUS`, `ERR-CREDENTIAL-BOGUS`, `ERR-VAULT-BOGUS` | N Live none admission and retry; C Live three hosted profiles; D/T/K/I recorded additional workflows; J DB creation-stream lifetime/snapshot/retry; M DB inline and saved-override configuration errors without writes; P DB verbatim whitespace Items and unchanged empty-input 400 without writes; MV DB M1–M8 tenant A/B, byte-identical not-found bodies and no-write digest | Session admission batch removes idle `none` creation; local idempotent create still differs from two official IDs. Stream retry, self-hosted, hosted and no-input stream lifetimes are local; work drained before a silent-settlement read can still be sent. Many input/tool/environment combinations restricted; harness admission limits keep the local code (TV-06). Empty-input 400s keep the local code and message; `["", text]` parts are accepted but unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6); the unknown-Vault message stays `Resource not found.`; a deleted selected credential still fails at dispatch rather than input (MV-04) | +| 7 | beta.agents.sessions.retrieve | P: persisted state, required actions, usage; malformed ID equals missing; `agent.reasoning` carries both keys, null when unset; usage is the recorded root Turn sum only when every root Turn has ended with known usage, otherwise null; an MCP tool without explicit `credential_id` shows the implicitly selected credential ID, also after deletion | S `retrieve-1.json`, `session-after-1.json`; H recovered state; X SES-28; SR SES-23, EVT-13, ST-03; MV MV-02 `S1-after-c1-delete-session-get`, `S1-final-session-get` | C Live history; T pending actions; H Core acceptance recorded; SR API/DB reasoning and usage rule; MV API/DB selected-credential projection before and after deletion | Complete statuses/actions/lifecycle timing; Claude/MiniMax public usage remains null; model-derived default effort not resolved (VA-11); queued-Turn usage unobserved (treated as not ended) | | 8 | beta.agents.sessions.update | P: metadata-only replacement/clear; metadata errors use official code and `metadata`/`metadata.` param | S `metadata-replace/null/empty/omit/invalid-value.json`; `update-agent.json` uses newer unpinned field | N Live completed Session metadata rejection/clear/isolation; recorded active controlled metadata coverage | Session admission batch changes empty update to observed official 400; Session agent update belongs to baseline upgrade, not fixed-pin operation gap | -| 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor, malformed included, is the missing 404 | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples; L SES-10/11/15/16/17; CE ERR-01 `cur-sessions-malformed`, ERR-13/14 | C Live order/cursors/empty/tenant checks; L DB tenant A/B; CE DB cursor matrix tenant A/B | Core page capacity 100; official cap unknown. Eventual visibility sample is not a required delay | +| 9 | beta.agents.sessions.list | P: Agent filter, full envelope, cursor paging; unknown keys ignored, limit 0/above 100 clamp; any unresolved cursor, malformed included, is the missing 404; Sessions project the selected MCP credential as retrieve does | S `list-filter.json`, `list-empty-after.json`, `list-owned-cross-filter-cursor.json`, limit/order/unknown-query samples; L SES-10/11/15/16/17; CE ERR-01 `cur-sessions-malformed`, ERR-13/14 | C Live order/cursors/empty/tenant checks; L DB tenant A/B; CE DB cursor matrix tenant A/B; MV DB selected-credential projection | Core page capacity 100; official cap unknown. Eventual visibility sample is not a required delay | | 10 | beta.agents.sessions.delete | P: deletion only of a durably idle or failed Session without required actions or pending input; a busy root Turn or pending reservation gives 409 `conflict_error` with no change (subagent child Turns and pending Environment file writes are not checked); owner repeat returns the same 200; owned managed cleanup, user compute retained | S cleanup files 200/deleted; retry-session active cleanup initially 409; Z SES-29 `q5-delete-repeat` 200, SES-30 `q5b-delete-while-in-progress` 409, `q5-delete-never-existed` 404, `q5-delete-while-running` 200 right after an `events.create` 202 | D recorded real cleanup; C cleanup separately recorded; Z DB matrix D1–D4 with no-write digest, admission lock race and pinned-SDK script | Core returns 409 right after an `events.create` 202 because it admits Turns synchronously (official 200); input awaiting its Environment cannot be cancelled publicly and is unobserved officially; physical purge/retention may end repeat idempotency; caller compute ownership preserved | | 11 | beta.agents.sessions.events.create | P: 202/empty body, empty-array authenticated no-op, text/cancel/function admission; whitespace-only text is admitted and stored verbatim, empty text/content/input keep the local 400; input the Session cannot accept (result after cancellation, batch while input is pending) and changed results give 409 `conflict_error`/`conflict_error`; in an owned Session an unknown call or a call of another Turn gives 400 `invalid_request_error` without writes; missing/foreign Sessions stay 404; key reuse keeps local 409 `idempotency_conflict` with type `conflict_error` | S `second-turn-create.json`, `events-empty/null.json`; H second-input; P SES-04 two whitespace-only messages 202, SES-06/07 empty text/content/input 400; CF EVT-11 `s2-result-unknown-call`/`s2-result-unknown-turn` 400, EVT-12 `s2-result-changed-after-terminal`/`s2-result-after-cancel` 409, EVT-14 duplicate 202, ERR-22 `sessB-message-while-running` 409 | C Live real continuation/no-op; T qualified message/result/cancel workflows; P DB verbatim whitespace Items and no-write empty-input rejection; CF DB rows CF2–CF4 and CF6–CF9 with tenant B, no-write digest and unchanged pending action, pinned-SDK result script | Mixed prepared-environment batches, native receipt vs durable acceptance, cancel-before-result-publication timing; unqualified content/tools; empty-input error code/message differ; `["", text]` unobserved officially (SES-08); Claude SDK and MiniMax Code reject whitespace-only messages at admission as a declared native limitation (W6); the official asynchronous pending-input window (ERR-22) is not emulated; Core messages omit call/executor IDs; official order between pending-input and target errors unobserved | -| 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections; terminal Turn events carry top-level Turn usage (null when unknown); new Turns start `turn.created`, user `item.added`, `session.in_progress`, `turn.in_progress`; Item events carry `output_index`, null for input Items; assistant text is added in progress with empty content, then an empty part, deltas (a non-streamed final in one delta) and the done events | H create/reconnect frames; no historical frames in sampled idle interval; J GET streams never ended, terminal `usage` present; SR EVT-09/10 `streams-s1..s4` | C Live; H recorded three-harness disconnect/recovery; T pending actions; J DB order/usage; SR DB sequences and API rendering | Full SSE/Item variants/order (EVT-05..08 deferred); child Turns and Items are not streamed on the Session (U, SAT-09) and are read through the Subagent routes; no replay guarantee or observer-disconnect proof for every state | +| 12 | beta.agents.sessions.events.stream | P: live-only SSE, typed persisted projections; terminal Turn events carry top-level Turn usage (null when unknown); new Turns start `turn.created`, user `item.added`, `session.in_progress`, `turn.in_progress`; Item events carry `output_index`, null for input Items; assistant text is added in progress with empty content, then an empty part, deltas (a non-streamed final in one delta) and the done events; Session snapshots project the selected MCP credential as retrieve does | H create/reconnect frames; no historical frames in sampled idle interval; J GET streams never ended, terminal `usage` present; SR EVT-09/10 `streams-s1..s4`; MV MV-02 `S1-T1-create-stream` snapshots | C Live; H recorded three-harness disconnect/recovery; T pending actions; J DB order/usage; SR DB sequences and API rendering; MV DB created, in-progress and idle snapshots | Full SSE/Item variants/order (EVT-05..08 deferred); child Turns and Items are not streamed on the Session (U, SAT-09) and are read through the Subagent routes; no replay guarantee or observer-disconnect proof for every state | | 13 | beta.agents.sessions.turns.retrieve | P: persisted root Turn identity; malformed and child Turn IDs equal missing | H/S contain Turn list payloads; no isolated positive retrieve raw request identified in this set; X SES-28 official `turn_` 404; U SAT-07 `C04` child Turn ID 404 | Recorded H/B scoped Turn recovery (B under the earlier mixed root/child contract); C history uses list; U DB child-ID 404 equal to missing, tenant B | Distinguish list-shape evidence from retrieve wire qualification; full lifecycle/usage; official 404 message text differs | | 14 | beta.agents.sessions.turns.list | P: full envelope, ordered root Turns only; a child Turn, malformed or other unresolved cursor equals a missing one; limit outside 1–100 rejects with the Beta code | S `turns-final/empty-page/limit-high/order-empty.json`; H both directions; L SES-14/15/16/17; X SES-28; U SAT-07 `R06` root-only list beside a child Turn; CE ERR-01 `cur-turns-malformed`, ERR-13 | C Live paging; H/B real child/root identity recorded under the earlier mixed contract; L DB tenant A/B; U DB root-only list and cursor; CE DB cursor matrix tenant A/B | All interleavings, same-timestamp paging, interim/failed usage | | 15 | beta.agents.sessions.items.list | P: scoped root Items, full envelope; limit 0/above 100 clamp within the pinned 1–100 page; a cursor that is not an Item of the Session is 400 ``Invalid session item ID in `after` ``; messages carry `phase`, null for user messages and when no native phase is reported; function results carry `output` and `error`, null when not submitted | S `items-final/empty-page/limit-high.json`; H both directions; L SES-12/13/15/16/17; CE ERR-02 `cur-items-*`; SR SES-25, EVT-09 `s2-items-after-t1`, `s4-items` | C Live; H/T recorded content/coordination/result variants; L DB tenant A/B; CE DB cursor matrix tenant A/B; SR DB/SDK null fields | Full Item union. Newer turn_id filter excluded from pin. Official pages showed a failed result's submitted array output as null; Core keeps the submitted content | diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 56d2650d6..70af5e677 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -551,8 +551,9 @@ cancellation, restart/history and credential lifecycle evidence. ### HTTP MCP execution -This section covers `agent.tools` with `connection_origin: "service"`. -Environment-origin Plugin declarations use the separate +This section covers `agent.tools` with `connection_origin: "service"`. An omitted +or null origin on HTTP transport is saved as `"service"`, exactly like the explicit +form. Environment-origin Plugin declarations use the separate [initialization and transport contract](../../contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins). Service-origin MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}`; Claude SDK supports HTTP MCP with @@ -588,8 +589,10 @@ service-origin MCP in V1, including anonymous requests. An explicit `credential_id` selects an attached credential for the exact HTTPS URL; omission/null selects a unique matching credential, or stays anonymous if none matches. Ambiguity -fails before Session creation. Selection is frozen privately; the public tool keeps -the caller's original credential field. See [credential setup and limits](credentials.md). +fails before Session creation with 409 `conflict_error`. Selection is frozen +privately; Session reads and events show an implicitly selected credential ID in the +public tool, while the stored request keeps the caller's field. See +[credential setup and limits](credentials.md). Authenticated execution additionally requires `mcp_http_bearer_auth`; missing keys or failed authorization/decryption never fall back to anonymous execution. @@ -614,7 +617,7 @@ gaps. Items retain the observed native JSON, which may differ from the original MCP envelope. See the [Claude SDK profile](../../CONTRIBUTING.md#claude-sdk-adapter-foundation). The current subset rejects native OAuth login, inline authorization, nonempty headers or -request metadata, URL userinfo/query/fragment, implicit/other origins, stdio +request metadata, URL userinfo/query/fragment, the `environment` origin, stdio and engines other than Codex/Claude SDK. The Codex adapter also rejects reserved native labels and stored native MCP credentials. It verifies exact effective MCP configuration before starting/resuming a native thread, diff --git a/services/agents-api/credentials.md b/services/agents-api/credentials.md index 64bc4d437..fa3258800 100644 --- a/services/agents-api/credentials.md +++ b/services/agents-api/credentials.md @@ -129,20 +129,36 @@ supports the documented `self_hosted` combination. The daemon must advertise bot `mcp_http_tools` and `mcp_http_bearer_auth`. The usual [MCP profile limits](README.md#http-mcp-execution) still apply. Without an explicit `credential_id`, one exact-URL static or OAuth credential among attached Vaults is selected; -zero matches remains anonymous and multiple matches fail. A foreign, missing, -unattached or wrong-destination reference returns the same local 404 before Session -creation. Saving a reference on an Agent does not authorize it for a Session. +zero matches remains anonymous. `connection_origin` may be omitted or null; it is +saved as `"service"`. Saving a reference on an Agent does not authorize it for a +Session. Selection failures use the official messages, with a null `param`, after +the input requirement and before anything is written: + +| Case | Response | +| --- | --- | +| `credential_id` without `vault_ids` | 400 `invalid_request_error`: `MCP credential_id requires an attached vault` | +| Missing, foreign-tenant, unattached or malformed reference | 400 `invalid_request_error`: `MCP credential_id was not found in an attached vault` | +| Credential in an attached Vault for another URL | 400 `invalid_request_error`: `MCP credential_id does not match server_url ` | +| Several implicit matches | 409 `conflict_error`: `multiple attached vault credentials match MCP server_url ; specify credential_id` | +| Unknown or foreign Vault in `vault_ids` | 404 `not_found_error` | + +`` and `` repeat the request's values only when they are at most 256 bytes +of printable UTF-8; otherwise the message leaves them out. Missing, foreign-tenant +and unattached references return identical responses for the same ID, so a +reference reveals nothing about Vaults the caller has not attached. The Session freezes its attachment list and private selection, including anonymous -decisions. Public tools retain the caller's `credential_id` value, including null. +decisions. Session reads, lists and event snapshots show an implicitly selected +credential ID in a null or omitted `credential_id`, also after that credential is +deleted. Anonymous tools stay null and explicit values are echoed as sent. The +stored request keeps the caller's field, so creation retries compare the original intent. Identical creation retries recover the accepted Session before selecting again; adding another credential does not change an existing binding. Each dispatch rechecks the complete scope before decryption. The token goes only through the private daemon request and a fresh native child environment variable, never public configuration, history, arguments or logs. Native execution requires nonempty RFC 6750 b64token bytes and rejects other opaque stored strings without trimming them. -Exact hosted matching, response population, selection timing and error/redirect -semantics remain unverified. +Exact hosted matching, selection timing and redirect semantics remain unverified. ## Replace a stored token diff --git a/services/agents-api/internal/api/agents.go b/services/agents-api/internal/api/agents.go index 20ed1bfdc..ed97cbb38 100644 --- a/services/agents-api/internal/api/agents.go +++ b/services/agents-api/internal/api/agents.go @@ -20,7 +20,7 @@ type AgentStore interface { } // @Summary Create a reusable Agent -// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, repeated, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id and explicit service origin and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains preserves null versus empty and a present location, including {}, includes all four keys with null for omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session execution admits only its supported configuration subset. +// @Description Persists configuration independently of execution. Names over 128 characters and metadata outside 16 string pairs with 64-character keys and 512-character values return invalid_request_error with the official param; U+0000 in stored strings is rejected as a local storage limit. Missing, unknown, repeated, wrongly typed or unsupported enum members of the pinned configuration shapes (tools, text, reasoning, service_tier, multi_agent) return invalid_request_error with the JSON path as param; duplicate function names, repeated web_search or tool_search and non-object schema root types return it with a null param. Supports model/name/instructions/metadata, explicit reasoning and service tiers, multi_agent, text/json_schema, function/tool_search/programmatic_tool_calling/web_search and HTTP MCP with nullable credential_id, service origin (omitted or null on HTTP transport is saved as service) and boolean required defaulting to false. Saving credential_id grants no access: Session admission checks attached Vault ownership and destination. MCP allowed_tools preserves null versus empty; saved HTTP transport includes empty headers. Model-derived reasoning defaults, other MCP variants and public retry conformance remain incomplete. web_search saves every pinned mode: omitted or null mode is saved as live and omitted or null context_size as medium; allowed_domains preserves null versus empty and a present location, including {}, includes all four keys with null for omitted ones, as observed officially (req_db41d2f6261b4abfb69465eafe719ab5, req_165d53b88445490b9146d8272c54134d). Session execution accepts only explicit disabled web_search and disabled programmatic_tool_calling through qualified Runtime controls; saved enabled forms reject at Session admission. Session execution admits only its supported configuration subset. // @Tags Agents // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index b5a95515b..8e51383b0 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -150,7 +150,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then deployment defaults. Hosted Sessions encrypt and freeze the resolved bundle; later Agent edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then deployment defaults. Hosted Sessions encrypt and freeze the resolved bundle; later Agent edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream From 5b07b4eb7db230ddc95b7946ccba22b6435fc100 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 21:02:45 +0000 Subject: [PATCH 4/5] Report non-HTTP MCP transports as unsupported with any origin A stdio tool with an omitted origin reported the explicit-origin error, although explicit service is rejected for it too. Non-HTTP transports now report that only HTTP transport is supported, with the same status, type and code. The official MCP script also deletes its reference Agent. --- .../agents-api/internal/api/mcp_configuration.go | 15 ++++++++++----- .../internal/api/mcp_configuration_test.go | 8 ++++++-- services/agents-api/tests/official_mcp.py | 8 ++++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/services/agents-api/internal/api/mcp_configuration.go b/services/agents-api/internal/api/mcp_configuration.go index e62c56ce0..6e70ead4b 100644 --- a/services/agents-api/internal/api/mcp_configuration.go +++ b/services/agents-api/internal/api/mcp_configuration.go @@ -9,6 +9,8 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" ) +const mcpHTTPOnly = "MCP currently supports HTTP transport only." + func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { var input v1.MCPToolInput if decodeInputObject(raw, &input, "type", "server_label", "transport", "allowed_tools", "connection_origin", "credential_id", "request_metadata", "required") != nil { @@ -19,13 +21,16 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { } // The official service saves an omitted or null origin on an HTTP server as // "service" (MV-01). Defaulting it here makes the stored and frozen - // configuration identical to an explicit declaration. Other transports keep - // the explicit requirement. - if input.ConnectionOrigin == nil && mcpHTTPTransport(input.Transport) { + // configuration identical to an explicit declaration. Other transports are + // unsupported with any origin. + if input.ConnectionOrigin == nil { + if !mcpHTTPTransport(input.Transport) { + return nil, errors.New(mcpHTTPOnly) + } service := "service" input.ConnectionOrigin = &service } - if input.ConnectionOrigin == nil || *input.ConnectionOrigin != "service" { + if *input.ConnectionOrigin != "service" { return nil, errors.New("MCP currently requires explicit connection_origin=service.") } if input.CredentialID != nil && *input.CredentialID == "" { @@ -44,7 +49,7 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { Headers json.RawMessage `json:"headers"` } if decodeInputObject(input.Transport, &transport, "type", "server_url", "headers") != nil || transport.Type != "http" || transport.ServerURL == nil { - return nil, errors.New("MCP currently supports HTTP transport only.") + return nil, errors.New(mcpHTTPOnly) } u, err := url.Parse(*transport.ServerURL) if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { diff --git a/services/agents-api/internal/api/mcp_configuration_test.go b/services/agents-api/internal/api/mcp_configuration_test.go index 9d37aea14..ad0da63a1 100644 --- a/services/agents-api/internal/api/mcp_configuration_test.go +++ b/services/agents-api/internal/api/mcp_configuration_test.go @@ -68,8 +68,12 @@ func TestMCPOmittedOriginIsService(t *testing.T) { } } } - if _, err := resolveMCPTool(json.RawMessage(`{"type":"mcp","server_label":"records","transport":{"type":"stdio","command":"run"}}`), true); err == nil || err.Error() != "MCP currently requires explicit connection_origin=service." { - t.Fatal("stdio transport lost its explicit origin rule", err) + // Other transports report the transport restriction with any origin. + for _, origin := range []string{"", `,"connection_origin":null`, `,"connection_origin":"service"`} { + input := `{"type":"mcp","server_label":"records"` + origin + `,"transport":{"type":"stdio","command":"run"}}` + if _, err := resolveMCPTool(json.RawMessage(input), true); err == nil || err.Error() != "MCP currently supports HTTP transport only." { + t.Fatalf("stdio origin %q: %v", origin, err) + } } } diff --git a/services/agents-api/tests/official_mcp.py b/services/agents-api/tests/official_mcp.py index 6a216a5e1..5823fd69f 100644 --- a/services/agents-api/tests/official_mcp.py +++ b/services/agents-api/tests/official_mcp.py @@ -57,6 +57,7 @@ def verify_mcp_configuration(client, other, expect_error): assert replaced.agent.tools == inline.agent.tools recovered.extend([inline, replaced]) saved.append(updated) + assert agents.delete(explicit["id"]).deleted before = {item.id for item in sessions.list()} saved_before = {item.id for item in agents.list()} @@ -68,8 +69,9 @@ def verify_mcp_configuration(client, other, expect_error): {"authorization": "synthetic-private"}, {"server_url": "https://mcp.example.invalid/mcp?token=synthetic-private"}): invalid.append({**tool, "transport": {**transport, **changes}}) - # Transports other than HTTP keep the explicit-origin requirement. - invalid.append({**minimal, "transport": {"type": "stdio", "command": "synthetic-private"}}) + # Transports other than HTTP stay unsupported with or without an origin. + stdio = {"type": "stdio", "command": "synthetic-private"} + invalid += [{**minimal, "transport": stdio}, {**tool, "transport": stdio}] for declaration in invalid: for operation in ( lambda: agents.create(model="requested-model", tools=[declaration]), @@ -78,6 +80,8 @@ def verify_mcp_configuration(client, other, expect_error): ): error = expect_error(BadRequestError, operation) assert "synthetic-private" not in str(error.body) + if declaration["transport"] is stdio: + assert error.body["message"] == "MCP currently supports HTTP transport only." assert {item.id for item in sessions.list()} == before assert {item.id for item in agents.list()} == saved_before print("HTTP MCP: pinned saved/Session projections, omitted/null origins, null/empty allowlists, immutable snapshots and rejected writes passed; no native execution claimed.") From bdb9c6ab068eb802d5fd0f2ce09d756a277b6f4b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 23 Sep 2026 21:02:45 +0000 Subject: [PATCH 5/5] Keep member order in the projected MCP credential The projection re-marshalled the tool through a map and reordered its members. Replace only the credential_id value in document order. The unit test checks the order; the stored caller intent is covered by the PostgreSQL storedNull guard. --- .../internal/api/session_credentials.go | 25 ++++++++++++++----- .../internal/api/session_credentials_test.go | 13 +++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/services/agents-api/internal/api/session_credentials.go b/services/agents-api/internal/api/session_credentials.go index bb2b514da..3ac958d6d 100644 --- a/services/agents-api/internal/api/session_credentials.go +++ b/services/agents-api/internal/api/session_credentials.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" @@ -64,16 +65,28 @@ func projectedMCPCredential(raw json.RawMessage, cfg configuration) json.RawMess if !attachedVault(cfg.VaultIDs, binding.VaultID) { return raw } - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { + // Keep the stored member order; only the credential_id value changes. + keys, fields := orderedMembers(raw) + if len(keys) == 0 || len(keys) != len(fields) { return raw } + if _, present := fields["credential_id"]; !present { + keys = append(keys, "credential_id") + } fields["credential_id"], _ = json.Marshal(binding.CredentialID) - projected, err := json.Marshal(fields) - if err != nil { - return raw + var projected bytes.Buffer + projected.WriteByte('{') + for index, key := range keys { + if index > 0 { + projected.WriteByte(',') + } + name, _ := json.Marshal(key) + projected.Write(name) + projected.WriteByte(':') + projected.Write(fields[key]) } - return projected + projected.WriteByte('}') + return projected.Bytes() } return raw } diff --git a/services/agents-api/internal/api/session_credentials_test.go b/services/agents-api/internal/api/session_credentials_test.go index 46433e8f3..07b1a11e3 100644 --- a/services/agents-api/internal/api/session_credentials_test.go +++ b/services/agents-api/internal/api/session_credentials_test.go @@ -86,12 +86,16 @@ func TestSessionProjectionShowsSelectedMCPCredential(t *testing.T) { cfg := configuration{Agent: v1.Agent{ID: "agent", Model: "model", Tools: []json.RawMessage{tool("implicit", ""), tool("anonymous", ""), tool("explicit", strings.ToUpper(explicit)), function}}, Environment: v1.Environment{Type: "none"}, VaultIDs: []string{strings.ToUpper(vault)}, MCPCredentials: []store.MCPCredentialBinding{binding("implicit", credential), binding("anonymous", ""), binding("explicit", explicit)}} + // The stored caller intent is checked against PostgreSQL by the storedNull + // guard in TestMCPCredentialSelectionPublicPostgres. raw, _ := json.Marshal(cfg) - stored := string(raw) response, err := sessionResponse(store.Session{Configuration: raw}, "") - if err != nil || !reflect.DeepEqual(response.VaultIDs, cfg.VaultIDs) || string(raw) != stored { - t.Fatal("public attachments lost or stored configuration changed", err) + if err != nil || !reflect.DeepEqual(response.VaultIDs, cfg.VaultIDs) { + t.Fatal("public attachments lost", err) } + // The stored member order, here the struct order rather than alphabetical, + // is kept by the projection. + toolKeys := []string{"type", "server_label", "transport", "allowed_tools", "connection_origin", "credential_id", "request_metadata", "required"} want := []any{credential, nil, strings.ToUpper(explicit)} for index, expected := range want { var projected map[string]any @@ -104,6 +108,9 @@ func TestSessionProjectionShowsSelectedMCPCredential(t *testing.T) { if !reflect.DeepEqual(projected, original) { t.Fatalf("tool %d changed beyond credential_id: %s", index, response.Agent.Tools[index]) } + if keys, _ := orderedMembers(response.Agent.Tools[index]); !reflect.DeepEqual(keys, toolKeys) { + t.Fatalf("tool %d member order changed: %v; want %v", index, keys, toolKeys) + } } if string(response.Agent.Tools[3]) != string(function) { t.Fatal("non-MCP tool changed")