fix(pact): add MCPPrompt.Writes so a write-directing prompt sits under the read/write ceiling

- pact.MCPPrompt.Writes (additive; the zero value keeps today's listing)
- pier's allowsPrompt hides a prompt declared with Writes from an identity
  without write, as it hides a tool that is not read-only (C-IN-01
  framework part, P-28)
This commit is contained in:
Jakub Zych
2026-10-23 16:08:27 +02:00
parent 104db9959f
commit 211cec11b0
5 changed files with 15 additions and 5 deletions

View File

@@ -151,7 +151,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasMCP` | Implemented by a plugin that declares MCP tools and prompts for the framework's MCP endpoint. |
| `pact.MCP` | The declaration: `Tools` and `Prompts` (resources are a later field of the same struct). |
| `pact.MCPTool` | One tool bound to one of the plugin's own token-guarded routes by `Method` and `Path`: unprefixed `Name`, static `Title` and `Description`, the `Guard` it runs as, the `Input` struct (zero value; `path`, `query`, `json` and `jsonschema` tags map and describe the arguments), an optional `Output` struct, extra `Permissions` for the listing and the `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` overrides of the annotations derived from the method (nil means derived). |
| `pact.MCPPrompt` | One prompt template: name, title, description, guard, permissions, `Arguments` (`pact.MCPPromptArgument`) and the `Render` func returning `pact.MCPPromptMessage` values (`Role` user or assistant, `Text`). |
| `pact.MCPPrompt` | One prompt template: name, title, description, guard, permissions, `Arguments` (`pact.MCPPromptArgument`), the `Render` func returning `pact.MCPPromptMessage` values (`Role` user or assistant, `Text`) and `Writes`, set when the prompt directs tools that are not read-only so it is listed only for an identity that may write (zero value: offered to read-only grants too). |
| `pact.MCPGuard` | A guard provider the owner of a route guard publishes: its name, whether a middleware list is token-guarded by it, the scopes and permissions a middleware list requires (`pact.MCPRequirements`), whether a subject may call a tool, and the pre-resolved principal for one in-process call (`pact.MCPDispatch`). |
| `pact.MCPGuards` | The registry of guard providers published on the application; `pact.NewMCPGuards` creates it, `Register` refuses an empty or duplicate name with a `pact:` error naming the guard, `Lookup` finds one and `Names` lists them sorted. Safe for concurrent use. |
| `pact.MCPSubject` | The identity an MCP session acts as under one guard: guard name, user id, granted scopes, OAuth client id and personal token id. |

View File

@@ -71,7 +71,10 @@ type MCPTool struct {
// MCPPrompt is one MCP prompt template. Name is prefixed and checked like a
// tool name; Guard and Permissions filter who is offered it, as for a tool.
// Render builds the messages for the given arguments (missing required
// arguments are refused before Render runs).
// arguments are refused before Render runs). Writes says the prompt directs
// tools that are not read-only, so the endpoint lists it only for an
// identity that may write, under the same read/write ceiling as such a
// tool; false (the zero value) offers it to read-only grants too.
type MCPPrompt struct {
Name string
Title string
@@ -80,6 +83,7 @@ type MCPPrompt struct {
Permissions []string
Arguments []MCPPromptArgument
Render func(ctx context.Context, args map[string]string) ([]MCPPromptMessage, error)
Writes bool
}
// MCPPromptArgument describes one argument of a prompt.

View File

@@ -42,7 +42,7 @@ Use cases:
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification. A server evicted from the LRU closes its sessions, and a stateless `subscriptions/listen` stream re-verifies its bearer every minute and ends when it no longer resolves to its binding, as the stateful GET stream does.
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event.
- Protected resource metadata (RFC 9728, `pier.PRMScopes`): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method; a `mcp.resource` that differs from the verifier's resource is a boot warning.
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route.
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A prompt declared with `Writes` (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without `write`.
- A 401 carries `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read.
- One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`: `open`, `rehydrate`, `end`, `binding_refused`, `revoked`, `stream_closed`, `expired`), never arguments, bodies or tokens. A tool call served on the stateful path without a session logs the session `ephemeral`, one on the stateless path `stateless`.

View File

@@ -8,7 +8,8 @@ import (
// allowed computes the tools and prompts an identity may see and call: a
// tool needs a subject under its guard, the read/write ceiling hides
// non-read-only tools from an identity without write (D-20), and the
// non-read-only tools and prompts declared with Writes from an identity
// without write (D-20), and the
// guard provider's own check decides the rest (D-16, D-23). A provider
// error hides the item and is logged. The same function builds the
// per-identity server and re-checks every list and call for the current
@@ -46,6 +47,11 @@ func (c *catalog) allowsTool(ctx context.Context, id Identity, t *tool) bool {
}
func (c *catalog) allowsPrompt(ctx context.Context, id Identity, p *prompt) bool {
// A prompt that directs write tools sits under the read/write ceiling
// like a tool that is not read-only (P-28).
if p.decl.Writes && !id.writes() {
return false
}
subject := id.subjectFor(p.guardName)
if subject == nil {
return false