@quirna/mcp 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/LICENSE +21 -0
- package/README.md +144 -1
- package/dist/bin.js +254 -0
- package/dist/config.d.ts +45 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +234 -0
- package/dist/outcome.d.ts +95 -0
- package/dist/server.d.ts +36 -0
- package/package.json +55 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to `@quirna/mcp`. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the versions
|
|
5
|
+
[semver](https://semver.org/), with the usual 0.x caveat that a minor bump may
|
|
6
|
+
still move something.
|
|
7
|
+
|
|
8
|
+
Two surfaces here are public and change under semver like any API: **the tools'
|
|
9
|
+
names and input schemas**, and **the text a tool returns**. The text is read by
|
|
10
|
+
a language model that acts on it, so rewording it is a product change, not
|
|
11
|
+
editing.
|
|
12
|
+
|
|
13
|
+
Releases are cut with the *MCP* workflow — see
|
|
14
|
+
[ADR-0030](../../docs/adr/0030-servidor-mcp.md).
|
|
15
|
+
|
|
16
|
+
## [0.1.0] — 2026-09-20
|
|
17
|
+
|
|
18
|
+
First release.
|
|
19
|
+
|
|
20
|
+
The mobile app is in **private beta**, and decisions are made there and nowhere
|
|
21
|
+
else. Write to hello@quirna.com and we will add you; until then an approval
|
|
22
|
+
request is created and then waits until it expires.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`request_approval`** — creates an Approval Request and blocks while a
|
|
27
|
+
nominated human decides on their phone. Takes `kind`, `message`, and
|
|
28
|
+
optionally `identifiers`, `environment` and `tier`.
|
|
29
|
+
|
|
30
|
+
It deliberately does not take who approves, how many are needed, or how long
|
|
31
|
+
the request stays open. Those live in your organization's Policy, so an agent
|
|
32
|
+
cannot choose its own approvers and neither can a prompt injection.
|
|
33
|
+
|
|
34
|
+
- **`check_approval`** — resumes a request that is already open, by id. Agent
|
|
35
|
+
clients kill long tool calls well before a human finishes deciding; without
|
|
36
|
+
this, an agent returning from a cut-off call could only create a second
|
|
37
|
+
request, putting the same action in front of a second person.
|
|
38
|
+
|
|
39
|
+
- **A declared `outputSchema`** on both tools, so `structuredContent` is a
|
|
40
|
+
contract a client can type against rather than JSON it may render if it
|
|
41
|
+
feels like it.
|
|
42
|
+
|
|
43
|
+
- **`quirna-mcp`**, the executable, configured through `QUIRNA_API_KEY`,
|
|
44
|
+
`QUIRNA_BASE_URL`, `QUIRNA_REQUESTER_ID`, `QUIRNA_REQUESTER_NAME`,
|
|
45
|
+
`QUIRNA_ENVIRONMENT` and `QUIRNA_WAIT_MS`.
|
|
46
|
+
|
|
47
|
+
### The rule worth knowing before you use it
|
|
48
|
+
|
|
49
|
+
**`isError` means the tool could not run, never that the answer was no.**
|
|
50
|
+
Approved, rejected, timed out, cancelled and still-pending are all successful
|
|
51
|
+
calls, because in all of them we asked and got an answer. `isError` is reserved
|
|
52
|
+
for learning nothing at all: the API unreachable, a key rejected, the call
|
|
53
|
+
aborted.
|
|
54
|
+
|
|
55
|
+
The verdict is carried by the text instead, and by its order — every
|
|
56
|
+
unauthorized outcome opens with `NOT AUTHORIZED` — plus `authorized` in
|
|
57
|
+
`structuredContent` for code that parses rather than reads.
|
|
58
|
+
|
|
59
|
+
Marking a denial as an error was tried first and is wrong in a way that looks
|
|
60
|
+
safer than it is: it contradicts `idempotentHint: false` (retrying asks a
|
|
61
|
+
second human), and it collapses "a person said no" and "the service is down"
|
|
62
|
+
into one bit. Nothing other than `approved` reads as permission.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quirna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
1
|
# @quirna/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ask a human before your agent acts. Official [Quirna](https://quirna.com)
|
|
4
|
+
server for the [Model Context Protocol](https://modelcontextprotocol.io).
|
|
5
|
+
|
|
6
|
+
Add one block of config and your agent gains a tool it can call before doing
|
|
7
|
+
something consequential — a refund, a production deploy, a destructive
|
|
8
|
+
migration. A named human approves it on their phone. The agent proceeds only
|
|
9
|
+
if they said yes, and the decision is signed and kept.
|
|
10
|
+
|
|
11
|
+
> **Nothing moves until someone says yes.**
|
|
12
|
+
|
|
13
|
+
Your agent's framework may already have a way to pause for confirmation. This
|
|
14
|
+
is different in three ways that matter once it is not just you at the terminal:
|
|
15
|
+
|
|
16
|
+
- **The decision leaves the loop.** It goes to whoever your policy nominates,
|
|
17
|
+
on their phone, behind Face ID — not to whoever happens to be watching the
|
|
18
|
+
agent run.
|
|
19
|
+
- **Who approves is policy, not prompt.** Quorum, risk tier and which group is
|
|
20
|
+
asked live in your organization's settings. The agent cannot choose its own
|
|
21
|
+
approvers, and neither can a prompt injection.
|
|
22
|
+
- **It leaves evidence.** Every decision is signed at the moment it is made,
|
|
23
|
+
and exports to a file a third party can verify without trusting us.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Nothing to install: the config below runs it on demand with `npx`. To pin it,
|
|
28
|
+
`npm install -g @quirna/mcp` and use `quirna-mcp` as the command.
|
|
29
|
+
|
|
30
|
+
## Before you start
|
|
31
|
+
|
|
32
|
+
> **The mobile app is in private beta.** Decisions are made on the phone and
|
|
33
|
+
> nowhere else — the Console configures policies and never decides one — so
|
|
34
|
+
> without the app an approval request is created and then waits until it
|
|
35
|
+
> expires. Write to **hello@quirna.com** and we will get you in. Everything
|
|
36
|
+
> else below works today.
|
|
37
|
+
|
|
38
|
+
Then, in the [Console](https://console.quirna.com):
|
|
39
|
+
|
|
40
|
+
1. **A System key.** Open **Users → Systems** and register one for this agent.
|
|
41
|
+
You get a key (`ck_…`), shown once.
|
|
42
|
+
2. **A Policy**, which decides who approves a given kind of request and how
|
|
43
|
+
many of them are needed.
|
|
44
|
+
3. **The app**, on the phone of whoever will approve.
|
|
45
|
+
|
|
46
|
+
## Configure your agent
|
|
47
|
+
|
|
48
|
+
**Claude Code** — `claude mcp add quirna --env QUIRNA_API_KEY=ck_… -- npx -y @quirna/mcp`
|
|
49
|
+
|
|
50
|
+
**Cursor** (`~/.cursor/mcp.json`), **Claude Desktop**
|
|
51
|
+
(`claude_desktop_config.json`) and most other clients take the same shape:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"quirna": {
|
|
57
|
+
"command": "npx",
|
|
58
|
+
"args": ["-y", "@quirna/mcp"],
|
|
59
|
+
"env": {
|
|
60
|
+
"QUIRNA_API_KEY": "ck_your_system_key",
|
|
61
|
+
"QUIRNA_REQUESTER_NAME": "Ops Agent",
|
|
62
|
+
"QUIRNA_ENVIRONMENT": "production"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Environment
|
|
70
|
+
|
|
71
|
+
| Variable | Default | What it does |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| `QUIRNA_API_KEY` | — | **Required.** The System key from the Console. |
|
|
74
|
+
| `QUIRNA_REQUESTER_NAME` | `AI agent` | The name an approver sees as having asked. |
|
|
75
|
+
| `QUIRNA_REQUESTER_ID` | `mcp` | Stable id for this caller in the audit trail. |
|
|
76
|
+
| `QUIRNA_ENVIRONMENT` | — | Applied when a call does not name one. |
|
|
77
|
+
| `QUIRNA_WAIT_MS` | `90000` | How long a tool call waits before reporting back. |
|
|
78
|
+
| `QUIRNA_BASE_URL` | `https://api.quirna.com` | Point at a self-hosted API. |
|
|
79
|
+
|
|
80
|
+
**On the key.** It lives in your agent client's config file, which is often in
|
|
81
|
+
a dotfile and sometimes in a repo. Treat it as a deployment credential: give
|
|
82
|
+
each agent its own System rather than sharing one, scope it in the Console to
|
|
83
|
+
the kinds it may request, and rotate it there if the file gets somewhere it
|
|
84
|
+
should not be. A System key can only *ask* — it can never approve anything.
|
|
85
|
+
|
|
86
|
+
## The tools
|
|
87
|
+
|
|
88
|
+
### `request_approval`
|
|
89
|
+
|
|
90
|
+
Creates the request and waits for a human.
|
|
91
|
+
|
|
92
|
+
| Argument | Required | |
|
|
93
|
+
| --- | --- | --- |
|
|
94
|
+
| `kind` | yes | The class of action, e.g. `database_migration`. Selects the policy. |
|
|
95
|
+
| `message` | yes | One imperative line, shown as the headline on the phone. |
|
|
96
|
+
| `identifiers` | no | Flat string key/values an approver needs: amounts, table names, hosts. |
|
|
97
|
+
| `environment` | no | `production`, `staging`, … |
|
|
98
|
+
| `tier` | no | `routine`, `elevated`, `critical`. Can only raise the policy's floor. |
|
|
99
|
+
|
|
100
|
+
Notably absent: who approves, how many are needed, how long it stays open.
|
|
101
|
+
Those are the organization's to decide in the Console, once, for every caller —
|
|
102
|
+
an agent that could pick its own approvers would not be a control.
|
|
103
|
+
|
|
104
|
+
### `check_approval`
|
|
105
|
+
|
|
106
|
+
Takes an `approval_id` and waits for that existing request. Use it when
|
|
107
|
+
`request_approval` came back still pending; calling `request_approval` again
|
|
108
|
+
would put the same action in front of a second human.
|
|
109
|
+
|
|
110
|
+
## What the agent gets back
|
|
111
|
+
|
|
112
|
+
**Anything that is not `APPROVED` opens with `NOT AUTHORIZED`**, followed by
|
|
113
|
+
why, and by an explicit instruction not to perform the action. The structured
|
|
114
|
+
result carries `authorized: true | false` for code that parses instead of
|
|
115
|
+
reading.
|
|
116
|
+
|
|
117
|
+
Asking and getting an answer is a successful tool call, whatever the answer
|
|
118
|
+
was — a denial is the product working, not a failure. `isError` is reserved for
|
|
119
|
+
the cases where the tool could not run at all and nothing was learned: Quirna
|
|
120
|
+
unreachable, a key rejected, the call aborted. So an agent can tell "a human
|
|
121
|
+
said no" from "the approval system is down", which are opposite situations, and
|
|
122
|
+
nothing invites it to retry a denial into a second person's hands.
|
|
123
|
+
|
|
124
|
+
### When nobody answers
|
|
125
|
+
|
|
126
|
+
An agent running unattended at 3am will hit this: the request expires and the
|
|
127
|
+
action is never authorized. That is the correct outcome, not a bug to design
|
|
128
|
+
around. If your agent needs to run without anyone awake, the answer is a policy
|
|
129
|
+
whose condition does not require a human for that case — not a longer timeout.
|
|
130
|
+
|
|
131
|
+
Two timeouts are in play, and they are not the same:
|
|
132
|
+
|
|
133
|
+
- **`QUIRNA_WAIT_MS`** (default 90s) is how long a *tool call* waits before
|
|
134
|
+
returning the approval id so the agent can resume. It exists because agent
|
|
135
|
+
clients kill long tool calls. The request stays live.
|
|
136
|
+
- **The policy's expiry** (set in the Console) is how long the *request* stays
|
|
137
|
+
open. Only this one can time a request out.
|
|
138
|
+
|
|
139
|
+
## Links
|
|
140
|
+
|
|
141
|
+
- [quirna.com](https://quirna.com) — what it is
|
|
142
|
+
- [console.quirna.com](https://console.quirna.com) — policies, groups, systems
|
|
143
|
+
- [`@quirna/sdk`](https://www.npmjs.com/package/@quirna/sdk) — for calling
|
|
144
|
+
Quirna from your own code instead
|
|
145
|
+
|
|
146
|
+
MIT © Quirna
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { Quirna } from "@quirna/sdk";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
var DEFAULT_WAIT_MS = 90000;
|
|
9
|
+
var DEFAULT_REQUESTER_ID = "mcp";
|
|
10
|
+
var DEFAULT_REQUESTER_NAME = "AI agent";
|
|
11
|
+
|
|
12
|
+
class ConfigError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ConfigError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function trimmed(env, name) {
|
|
19
|
+
const value = env[name]?.trim();
|
|
20
|
+
return value ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
function readConfig(env) {
|
|
23
|
+
const apiKey = trimmed(env, "QUIRNA_API_KEY");
|
|
24
|
+
if (!apiKey) {
|
|
25
|
+
throw new ConfigError("QUIRNA_API_KEY is not set. Register a System in the Quirna Console " + "(Users → Systems) and put its key in this server's env block.");
|
|
26
|
+
}
|
|
27
|
+
const rawWait = trimmed(env, "QUIRNA_WAIT_MS");
|
|
28
|
+
let waitMs = DEFAULT_WAIT_MS;
|
|
29
|
+
if (rawWait !== undefined) {
|
|
30
|
+
const parsed = Number(rawWait);
|
|
31
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
32
|
+
throw new ConfigError(`QUIRNA_WAIT_MS must be a positive number of milliseconds, got ${rawWait}`);
|
|
33
|
+
}
|
|
34
|
+
waitMs = parsed;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
apiKey,
|
|
38
|
+
baseUrl: trimmed(env, "QUIRNA_BASE_URL"),
|
|
39
|
+
requesterId: trimmed(env, "QUIRNA_REQUESTER_ID") ?? DEFAULT_REQUESTER_ID,
|
|
40
|
+
requesterName: trimmed(env, "QUIRNA_REQUESTER_NAME") ?? DEFAULT_REQUESTER_NAME,
|
|
41
|
+
environment: trimmed(env, "QUIRNA_ENVIRONMENT"),
|
|
42
|
+
waitMs
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/server.ts
|
|
47
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
48
|
+
import {
|
|
49
|
+
QuirnaError,
|
|
50
|
+
TIERS as TIERS2
|
|
51
|
+
} from "@quirna/sdk";
|
|
52
|
+
import { z as z2 } from "zod";
|
|
53
|
+
|
|
54
|
+
// src/outcome.ts
|
|
55
|
+
import {
|
|
56
|
+
APPROVAL_STATUSES,
|
|
57
|
+
TIERS
|
|
58
|
+
} from "@quirna/sdk";
|
|
59
|
+
import { z } from "zod";
|
|
60
|
+
var OUTPUT_SCHEMA = {
|
|
61
|
+
approval_id: z.string().describe("The Quirna id for this request."),
|
|
62
|
+
status: z.enum([...APPROVAL_STATUSES]),
|
|
63
|
+
authorized: z.boolean().describe("True only when the action may proceed. The one field worth branching on."),
|
|
64
|
+
kind: z.string(),
|
|
65
|
+
message: z.string(),
|
|
66
|
+
tier: z.enum([...TIERS]),
|
|
67
|
+
environment: z.string().nullable(),
|
|
68
|
+
auto_approved: z.boolean().describe("True when a Policy condition did not match and no human was asked."),
|
|
69
|
+
decided_by: z.string().nullable().describe("User id of the decider, not a name (ZUR-68)."),
|
|
70
|
+
decided_at: z.string().nullable(),
|
|
71
|
+
approved_count: z.number(),
|
|
72
|
+
policy: z.string().describe("Name of the Policy that governed this request."),
|
|
73
|
+
timeout_at: z.string().describe("When the request expires if still undecided.")
|
|
74
|
+
};
|
|
75
|
+
function structured(approval, authorized) {
|
|
76
|
+
return {
|
|
77
|
+
approval_id: approval.id,
|
|
78
|
+
status: approval.status,
|
|
79
|
+
authorized,
|
|
80
|
+
kind: approval.kind,
|
|
81
|
+
message: approval.message,
|
|
82
|
+
tier: approval.tier,
|
|
83
|
+
environment: approval.environment,
|
|
84
|
+
auto_approved: approval.auto_approved ?? false,
|
|
85
|
+
decided_by: approval.decided_by,
|
|
86
|
+
decided_at: approval.decided_at,
|
|
87
|
+
approved_count: approval.approved_count,
|
|
88
|
+
policy: approval.policy.name,
|
|
89
|
+
timeout_at: approval.timeout_at
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function result(approval, authorized, text) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text }],
|
|
95
|
+
structuredContent: structured(approval, authorized),
|
|
96
|
+
isError: false
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
var DO_NOT_PROCEED = "Do NOT perform the action. Report this outcome to the user instead of working around it.";
|
|
100
|
+
function describe(approval) {
|
|
101
|
+
switch (approval.status) {
|
|
102
|
+
case "approved": {
|
|
103
|
+
if (approval.auto_approved) {
|
|
104
|
+
return result(approval, true, `APPROVED automatically (${approval.id}). The "${approval.policy.name}" policy's ` + "condition did not match this request, so no human was asked. You may proceed.");
|
|
105
|
+
}
|
|
106
|
+
const when = approval.decided_at ? ` at ${approval.decided_at}` : "";
|
|
107
|
+
return result(approval, true, `APPROVED (${approval.id}). Authorized by an approver${when} under the ` + `"${approval.policy.name}" policy. You may proceed with: ${approval.message}`);
|
|
108
|
+
}
|
|
109
|
+
case "rejected":
|
|
110
|
+
return result(approval, false, `NOT AUTHORIZED — REJECTED (${approval.id}). A human explicitly denied this. ` + DO_NOT_PROCEED);
|
|
111
|
+
case "timed_out":
|
|
112
|
+
return result(approval, false, `NOT AUTHORIZED — TIMED OUT (${approval.id}). Nobody decided before the request ` + `expired at ${approval.timeout_at}. ${DO_NOT_PROCEED}`);
|
|
113
|
+
case "cancelled":
|
|
114
|
+
return result(approval, false, `NOT AUTHORIZED — CANCELLED (${approval.id}). The request was withdrawn before ` + `anyone decided it. ${DO_NOT_PROCEED}`);
|
|
115
|
+
case "pending":
|
|
116
|
+
return result(approval, false, `NOT AUTHORIZED YET — STILL PENDING (${approval.id}). The request is live and ` + `waiting on a human; it expires at ${approval.timeout_at}. ${DO_NOT_PROCEED} ` + `To pick the answer back up, call check_approval with approval_id "${approval.id}" — ` + "never request_approval again, which would ask a second person to approve the same thing.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/server.ts
|
|
121
|
+
var TIER_VALUES = [...TIERS2];
|
|
122
|
+
var SERVER_NAME = "quirna";
|
|
123
|
+
var SERVER_VERSION = "0.1.0";
|
|
124
|
+
var REQUEST_DESCRIPTION = [
|
|
125
|
+
"Ask a human to authorize an action before you take it, and wait for their answer.",
|
|
126
|
+
"",
|
|
127
|
+
"Call this BEFORE doing anything that is irreversible, destructive, spends money,",
|
|
128
|
+
"touches production, affects other people, or goes beyond what the user explicitly",
|
|
129
|
+
"asked for. Describe the action you are about to take, then act only if the result",
|
|
130
|
+
"says APPROVED.",
|
|
131
|
+
"",
|
|
132
|
+
"A nominated approver decides on their phone under your organization's policy — you",
|
|
133
|
+
"do not choose who approves or how many are needed. The call blocks while they",
|
|
134
|
+
"decide. If they have not answered by the time it returns, you get the approval id",
|
|
135
|
+
"and resume with check_approval; the request stays live either way."
|
|
136
|
+
].join(`
|
|
137
|
+
`);
|
|
138
|
+
var CHECK_DESCRIPTION = [
|
|
139
|
+
"Look up an approval request you already created and wait for its outcome.",
|
|
140
|
+
"",
|
|
141
|
+
"Use this when a previous request_approval call came back NOT DECIDED YET. Never",
|
|
142
|
+
"call request_approval again for the same action — that asks a second human to",
|
|
143
|
+
"approve something already in front of the first one."
|
|
144
|
+
].join(`
|
|
145
|
+
`);
|
|
146
|
+
function createServer(config, client) {
|
|
147
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
148
|
+
instructions: "Quirna puts a human in front of risky actions. Before taking an action that " + "is irreversible, destructive, costly, or outside what the user asked for, call " + "request_approval and proceed only on APPROVED."
|
|
149
|
+
});
|
|
150
|
+
server.registerTool("request_approval", {
|
|
151
|
+
title: "Request human approval",
|
|
152
|
+
description: REQUEST_DESCRIPTION,
|
|
153
|
+
inputSchema: {
|
|
154
|
+
kind: z2.string().min(1).describe('What class of action this is, in snake_case — for example "database_migration", ' + `"refund", "deploy". The organization's policy for this kind decides who is ` + "asked and whether a human is needed at all."),
|
|
155
|
+
message: z2.string().min(1).describe("One imperative line naming the action, shown as the headline on the approver's " + 'phone: "Drop table users_v1 on production". Write it so someone who cannot see ' + "your session can judge it."),
|
|
156
|
+
identifiers: z2.record(z2.string(), z2.string()).optional().describe("The specifics an approver needs, as flat string key/value pairs: amounts, table " + 'names, account ids, hostnames. For example {"table": "users_v1", "rows": "48213"}. ' + "Policy conditions are evaluated against these."),
|
|
157
|
+
environment: z2.string().optional().describe('Deployment label, for example "production" or "staging".'),
|
|
158
|
+
tier: z2.enum(TIER_VALUES).optional().describe("Raise the risk tier shown to the approver. The policy sets the floor; this can " + "only raise it, never lower it.")
|
|
159
|
+
},
|
|
160
|
+
outputSchema: OUTPUT_SCHEMA,
|
|
161
|
+
annotations: {
|
|
162
|
+
readOnlyHint: false,
|
|
163
|
+
destructiveHint: false,
|
|
164
|
+
idempotentHint: false,
|
|
165
|
+
openWorldHint: true
|
|
166
|
+
}
|
|
167
|
+
}, async (args, extra) => {
|
|
168
|
+
const input = {
|
|
169
|
+
kind: args.kind,
|
|
170
|
+
message: args.message,
|
|
171
|
+
identifiers: args.identifiers ?? {},
|
|
172
|
+
requester_id: config.requesterId,
|
|
173
|
+
requester_name: config.requesterName,
|
|
174
|
+
...args.environment ?? config.environment ? { environment: args.environment ?? config.environment } : {},
|
|
175
|
+
...args.tier ? { tier: args.tier } : {}
|
|
176
|
+
};
|
|
177
|
+
return run(async () => {
|
|
178
|
+
const created = await client.create(input, { signal: extra.signal });
|
|
179
|
+
if (created.status !== "pending")
|
|
180
|
+
return describe(created);
|
|
181
|
+
return describe(await settle(client, created.id, config.waitMs, extra.signal));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
server.registerTool("check_approval", {
|
|
185
|
+
title: "Check a pending approval",
|
|
186
|
+
description: CHECK_DESCRIPTION,
|
|
187
|
+
inputSchema: {
|
|
188
|
+
approval_id: z2.string().min(1).describe('The id from an earlier request_approval result, such as "apr_7x2k9m".')
|
|
189
|
+
},
|
|
190
|
+
outputSchema: OUTPUT_SCHEMA,
|
|
191
|
+
annotations: {
|
|
192
|
+
readOnlyHint: true,
|
|
193
|
+
destructiveHint: false,
|
|
194
|
+
idempotentHint: true,
|
|
195
|
+
openWorldHint: true
|
|
196
|
+
}
|
|
197
|
+
}, async (args, extra) => run(async () => describe(await settle(client, args.approval_id, config.waitMs, extra.signal))));
|
|
198
|
+
return server;
|
|
199
|
+
}
|
|
200
|
+
async function settle(client, id, waitMs, signal) {
|
|
201
|
+
try {
|
|
202
|
+
return await client.wait(id, { timeoutMs: waitMs, signal });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (err instanceof QuirnaError && err.code === "wait_timeout") {
|
|
205
|
+
return client.get(id, { signal });
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function run(body) {
|
|
211
|
+
try {
|
|
212
|
+
return await body();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: "text", text: failureText(err) }],
|
|
216
|
+
structuredContent: {},
|
|
217
|
+
isError: true
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function failureText(err) {
|
|
222
|
+
const prefix = "NOT AUTHORIZED —";
|
|
223
|
+
const suffix = "Do NOT perform the action; tell the user Quirna could not answer.";
|
|
224
|
+
if (err instanceof QuirnaError) {
|
|
225
|
+
if (err.code === "aborted") {
|
|
226
|
+
return `${prefix} the approval request was cancelled before it was decided. ${suffix}`;
|
|
227
|
+
}
|
|
228
|
+
const hint = err.status === 401 ? " Check QUIRNA_API_KEY: the System key was rejected." : err.status === 403 ? " This System is not allowed to request that kind; an Org admin sets the list in the Console under Users → Systems." : "";
|
|
229
|
+
return `${prefix} Quirna returned ${err.status}: ${err.message}.${hint} ${suffix}`;
|
|
230
|
+
}
|
|
231
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
232
|
+
return `${prefix} could not reach Quirna: ${message}. ${suffix}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/bin.ts
|
|
236
|
+
async function main() {
|
|
237
|
+
const config = readConfig(process.env);
|
|
238
|
+
const client = new Quirna({
|
|
239
|
+
apiKey: config.apiKey,
|
|
240
|
+
...config.baseUrl ? { baseUrl: config.baseUrl } : {},
|
|
241
|
+
timeoutMs: 30000
|
|
242
|
+
});
|
|
243
|
+
const server = createServer(config, client);
|
|
244
|
+
await server.connect(new StdioServerTransport);
|
|
245
|
+
process.stderr.write(`quirna-mcp: ready (waiting up to ${Math.round(config.waitMs / 1000)}s per approval)
|
|
246
|
+
`);
|
|
247
|
+
}
|
|
248
|
+
main().catch((err) => {
|
|
249
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
250
|
+
process.stderr.write(err instanceof ConfigError ? `quirna-mcp: ${message}
|
|
251
|
+
` : `quirna-mcp: failed to start: ${message}
|
|
252
|
+
`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every environment variable this server reads, in one place.
|
|
3
|
+
*
|
|
4
|
+
* An MCP server is configured by a block of JSON in someone else's agent
|
|
5
|
+
* client, so env vars are the whole configuration surface — there is no
|
|
6
|
+
* flag to pass and no file of ours to edit. Each one below is therefore
|
|
7
|
+
* documented as if it were a public API, because it is.
|
|
8
|
+
*/
|
|
9
|
+
export type ServerConfig = {
|
|
10
|
+
apiKey: string;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Who the Console shows as having asked. One System key per agent, ideally. */
|
|
13
|
+
requesterId: string;
|
|
14
|
+
requesterName: string;
|
|
15
|
+
/** Applied when a tool call does not name one. Empty means the org default. */
|
|
16
|
+
environment?: string;
|
|
17
|
+
/**
|
|
18
|
+
* How long a tool call blocks waiting for a human before reporting back
|
|
19
|
+
* that the request is still pending.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately short by default. The limit that matters is not ours — it
|
|
22
|
+
* is the agent client's own tool-call timeout, which is measured in tens
|
|
23
|
+
* of seconds and, when it fires, kills the call without telling the model
|
|
24
|
+
* anything. Coming back first with an approval id the model can resume
|
|
25
|
+
* from is strictly better than being killed holding it.
|
|
26
|
+
*/
|
|
27
|
+
waitMs: number;
|
|
28
|
+
};
|
|
29
|
+
export declare const DEFAULT_WAIT_MS = 90000;
|
|
30
|
+
export declare const DEFAULT_REQUESTER_ID = "mcp";
|
|
31
|
+
export declare const DEFAULT_REQUESTER_NAME = "AI agent";
|
|
32
|
+
/** Thrown for a configuration problem, which is always the operator's to fix. */
|
|
33
|
+
export declare class ConfigError extends Error {
|
|
34
|
+
constructor(message: string);
|
|
35
|
+
}
|
|
36
|
+
export type Env = Record<string, string | undefined>;
|
|
37
|
+
/**
|
|
38
|
+
* Read the configuration, or throw a `ConfigError` naming the variable and
|
|
39
|
+
* where to get its value.
|
|
40
|
+
*
|
|
41
|
+
* The error text matters more here than anywhere else in the package: it is
|
|
42
|
+
* printed to stderr by an agent client that shows the user, at best, a red
|
|
43
|
+
* line saying the server failed to start.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readConfig(env: Env): ServerConfig;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library surface, for embedding the Quirna tools in a server of your
|
|
3
|
+
* own. Running it as a standalone MCP server needs none of this — that is
|
|
4
|
+
* `bin.ts`, published as the `quirna-mcp` executable.
|
|
5
|
+
*/
|
|
6
|
+
export { ConfigError, DEFAULT_REQUESTER_ID, DEFAULT_REQUESTER_NAME, DEFAULT_WAIT_MS, type Env, readConfig, type ServerConfig, } from "./config.js";
|
|
7
|
+
export { describe, type ToolResult } from "./outcome.js";
|
|
8
|
+
export { type ApprovalsClient, createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
var DEFAULT_WAIT_MS = 90000;
|
|
3
|
+
var DEFAULT_REQUESTER_ID = "mcp";
|
|
4
|
+
var DEFAULT_REQUESTER_NAME = "AI agent";
|
|
5
|
+
|
|
6
|
+
class ConfigError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ConfigError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function trimmed(env, name) {
|
|
13
|
+
const value = env[name]?.trim();
|
|
14
|
+
return value ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
function readConfig(env) {
|
|
17
|
+
const apiKey = trimmed(env, "QUIRNA_API_KEY");
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
throw new ConfigError("QUIRNA_API_KEY is not set. Register a System in the Quirna Console " + "(Users → Systems) and put its key in this server's env block.");
|
|
20
|
+
}
|
|
21
|
+
const rawWait = trimmed(env, "QUIRNA_WAIT_MS");
|
|
22
|
+
let waitMs = DEFAULT_WAIT_MS;
|
|
23
|
+
if (rawWait !== undefined) {
|
|
24
|
+
const parsed = Number(rawWait);
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
26
|
+
throw new ConfigError(`QUIRNA_WAIT_MS must be a positive number of milliseconds, got ${rawWait}`);
|
|
27
|
+
}
|
|
28
|
+
waitMs = parsed;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
apiKey,
|
|
32
|
+
baseUrl: trimmed(env, "QUIRNA_BASE_URL"),
|
|
33
|
+
requesterId: trimmed(env, "QUIRNA_REQUESTER_ID") ?? DEFAULT_REQUESTER_ID,
|
|
34
|
+
requesterName: trimmed(env, "QUIRNA_REQUESTER_NAME") ?? DEFAULT_REQUESTER_NAME,
|
|
35
|
+
environment: trimmed(env, "QUIRNA_ENVIRONMENT"),
|
|
36
|
+
waitMs
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// src/outcome.ts
|
|
40
|
+
import {
|
|
41
|
+
APPROVAL_STATUSES,
|
|
42
|
+
TIERS
|
|
43
|
+
} from "@quirna/sdk";
|
|
44
|
+
import { z } from "zod";
|
|
45
|
+
var OUTPUT_SCHEMA = {
|
|
46
|
+
approval_id: z.string().describe("The Quirna id for this request."),
|
|
47
|
+
status: z.enum([...APPROVAL_STATUSES]),
|
|
48
|
+
authorized: z.boolean().describe("True only when the action may proceed. The one field worth branching on."),
|
|
49
|
+
kind: z.string(),
|
|
50
|
+
message: z.string(),
|
|
51
|
+
tier: z.enum([...TIERS]),
|
|
52
|
+
environment: z.string().nullable(),
|
|
53
|
+
auto_approved: z.boolean().describe("True when a Policy condition did not match and no human was asked."),
|
|
54
|
+
decided_by: z.string().nullable().describe("User id of the decider, not a name (ZUR-68)."),
|
|
55
|
+
decided_at: z.string().nullable(),
|
|
56
|
+
approved_count: z.number(),
|
|
57
|
+
policy: z.string().describe("Name of the Policy that governed this request."),
|
|
58
|
+
timeout_at: z.string().describe("When the request expires if still undecided.")
|
|
59
|
+
};
|
|
60
|
+
function structured(approval, authorized) {
|
|
61
|
+
return {
|
|
62
|
+
approval_id: approval.id,
|
|
63
|
+
status: approval.status,
|
|
64
|
+
authorized,
|
|
65
|
+
kind: approval.kind,
|
|
66
|
+
message: approval.message,
|
|
67
|
+
tier: approval.tier,
|
|
68
|
+
environment: approval.environment,
|
|
69
|
+
auto_approved: approval.auto_approved ?? false,
|
|
70
|
+
decided_by: approval.decided_by,
|
|
71
|
+
decided_at: approval.decided_at,
|
|
72
|
+
approved_count: approval.approved_count,
|
|
73
|
+
policy: approval.policy.name,
|
|
74
|
+
timeout_at: approval.timeout_at
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function result(approval, authorized, text) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text }],
|
|
80
|
+
structuredContent: structured(approval, authorized),
|
|
81
|
+
isError: false
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
var DO_NOT_PROCEED = "Do NOT perform the action. Report this outcome to the user instead of working around it.";
|
|
85
|
+
function describe(approval) {
|
|
86
|
+
switch (approval.status) {
|
|
87
|
+
case "approved": {
|
|
88
|
+
if (approval.auto_approved) {
|
|
89
|
+
return result(approval, true, `APPROVED automatically (${approval.id}). The "${approval.policy.name}" policy's ` + "condition did not match this request, so no human was asked. You may proceed.");
|
|
90
|
+
}
|
|
91
|
+
const when = approval.decided_at ? ` at ${approval.decided_at}` : "";
|
|
92
|
+
return result(approval, true, `APPROVED (${approval.id}). Authorized by an approver${when} under the ` + `"${approval.policy.name}" policy. You may proceed with: ${approval.message}`);
|
|
93
|
+
}
|
|
94
|
+
case "rejected":
|
|
95
|
+
return result(approval, false, `NOT AUTHORIZED — REJECTED (${approval.id}). A human explicitly denied this. ` + DO_NOT_PROCEED);
|
|
96
|
+
case "timed_out":
|
|
97
|
+
return result(approval, false, `NOT AUTHORIZED — TIMED OUT (${approval.id}). Nobody decided before the request ` + `expired at ${approval.timeout_at}. ${DO_NOT_PROCEED}`);
|
|
98
|
+
case "cancelled":
|
|
99
|
+
return result(approval, false, `NOT AUTHORIZED — CANCELLED (${approval.id}). The request was withdrawn before ` + `anyone decided it. ${DO_NOT_PROCEED}`);
|
|
100
|
+
case "pending":
|
|
101
|
+
return result(approval, false, `NOT AUTHORIZED YET — STILL PENDING (${approval.id}). The request is live and ` + `waiting on a human; it expires at ${approval.timeout_at}. ${DO_NOT_PROCEED} ` + `To pick the answer back up, call check_approval with approval_id "${approval.id}" — ` + "never request_approval again, which would ask a second person to approve the same thing.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// src/server.ts
|
|
105
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
106
|
+
import {
|
|
107
|
+
QuirnaError,
|
|
108
|
+
TIERS as TIERS2
|
|
109
|
+
} from "@quirna/sdk";
|
|
110
|
+
import { z as z2 } from "zod";
|
|
111
|
+
var TIER_VALUES = [...TIERS2];
|
|
112
|
+
var SERVER_NAME = "quirna";
|
|
113
|
+
var SERVER_VERSION = "0.1.0";
|
|
114
|
+
var REQUEST_DESCRIPTION = [
|
|
115
|
+
"Ask a human to authorize an action before you take it, and wait for their answer.",
|
|
116
|
+
"",
|
|
117
|
+
"Call this BEFORE doing anything that is irreversible, destructive, spends money,",
|
|
118
|
+
"touches production, affects other people, or goes beyond what the user explicitly",
|
|
119
|
+
"asked for. Describe the action you are about to take, then act only if the result",
|
|
120
|
+
"says APPROVED.",
|
|
121
|
+
"",
|
|
122
|
+
"A nominated approver decides on their phone under your organization's policy — you",
|
|
123
|
+
"do not choose who approves or how many are needed. The call blocks while they",
|
|
124
|
+
"decide. If they have not answered by the time it returns, you get the approval id",
|
|
125
|
+
"and resume with check_approval; the request stays live either way."
|
|
126
|
+
].join(`
|
|
127
|
+
`);
|
|
128
|
+
var CHECK_DESCRIPTION = [
|
|
129
|
+
"Look up an approval request you already created and wait for its outcome.",
|
|
130
|
+
"",
|
|
131
|
+
"Use this when a previous request_approval call came back NOT DECIDED YET. Never",
|
|
132
|
+
"call request_approval again for the same action — that asks a second human to",
|
|
133
|
+
"approve something already in front of the first one."
|
|
134
|
+
].join(`
|
|
135
|
+
`);
|
|
136
|
+
function createServer(config, client) {
|
|
137
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
138
|
+
instructions: "Quirna puts a human in front of risky actions. Before taking an action that " + "is irreversible, destructive, costly, or outside what the user asked for, call " + "request_approval and proceed only on APPROVED."
|
|
139
|
+
});
|
|
140
|
+
server.registerTool("request_approval", {
|
|
141
|
+
title: "Request human approval",
|
|
142
|
+
description: REQUEST_DESCRIPTION,
|
|
143
|
+
inputSchema: {
|
|
144
|
+
kind: z2.string().min(1).describe('What class of action this is, in snake_case — for example "database_migration", ' + `"refund", "deploy". The organization's policy for this kind decides who is ` + "asked and whether a human is needed at all."),
|
|
145
|
+
message: z2.string().min(1).describe("One imperative line naming the action, shown as the headline on the approver's " + 'phone: "Drop table users_v1 on production". Write it so someone who cannot see ' + "your session can judge it."),
|
|
146
|
+
identifiers: z2.record(z2.string(), z2.string()).optional().describe("The specifics an approver needs, as flat string key/value pairs: amounts, table " + 'names, account ids, hostnames. For example {"table": "users_v1", "rows": "48213"}. ' + "Policy conditions are evaluated against these."),
|
|
147
|
+
environment: z2.string().optional().describe('Deployment label, for example "production" or "staging".'),
|
|
148
|
+
tier: z2.enum(TIER_VALUES).optional().describe("Raise the risk tier shown to the approver. The policy sets the floor; this can " + "only raise it, never lower it.")
|
|
149
|
+
},
|
|
150
|
+
outputSchema: OUTPUT_SCHEMA,
|
|
151
|
+
annotations: {
|
|
152
|
+
readOnlyHint: false,
|
|
153
|
+
destructiveHint: false,
|
|
154
|
+
idempotentHint: false,
|
|
155
|
+
openWorldHint: true
|
|
156
|
+
}
|
|
157
|
+
}, async (args, extra) => {
|
|
158
|
+
const input = {
|
|
159
|
+
kind: args.kind,
|
|
160
|
+
message: args.message,
|
|
161
|
+
identifiers: args.identifiers ?? {},
|
|
162
|
+
requester_id: config.requesterId,
|
|
163
|
+
requester_name: config.requesterName,
|
|
164
|
+
...args.environment ?? config.environment ? { environment: args.environment ?? config.environment } : {},
|
|
165
|
+
...args.tier ? { tier: args.tier } : {}
|
|
166
|
+
};
|
|
167
|
+
return run(async () => {
|
|
168
|
+
const created = await client.create(input, { signal: extra.signal });
|
|
169
|
+
if (created.status !== "pending")
|
|
170
|
+
return describe(created);
|
|
171
|
+
return describe(await settle(client, created.id, config.waitMs, extra.signal));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
server.registerTool("check_approval", {
|
|
175
|
+
title: "Check a pending approval",
|
|
176
|
+
description: CHECK_DESCRIPTION,
|
|
177
|
+
inputSchema: {
|
|
178
|
+
approval_id: z2.string().min(1).describe('The id from an earlier request_approval result, such as "apr_7x2k9m".')
|
|
179
|
+
},
|
|
180
|
+
outputSchema: OUTPUT_SCHEMA,
|
|
181
|
+
annotations: {
|
|
182
|
+
readOnlyHint: true,
|
|
183
|
+
destructiveHint: false,
|
|
184
|
+
idempotentHint: true,
|
|
185
|
+
openWorldHint: true
|
|
186
|
+
}
|
|
187
|
+
}, async (args, extra) => run(async () => describe(await settle(client, args.approval_id, config.waitMs, extra.signal))));
|
|
188
|
+
return server;
|
|
189
|
+
}
|
|
190
|
+
async function settle(client, id, waitMs, signal) {
|
|
191
|
+
try {
|
|
192
|
+
return await client.wait(id, { timeoutMs: waitMs, signal });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err instanceof QuirnaError && err.code === "wait_timeout") {
|
|
195
|
+
return client.get(id, { signal });
|
|
196
|
+
}
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function run(body) {
|
|
201
|
+
try {
|
|
202
|
+
return await body();
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return {
|
|
205
|
+
content: [{ type: "text", text: failureText(err) }],
|
|
206
|
+
structuredContent: {},
|
|
207
|
+
isError: true
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function failureText(err) {
|
|
212
|
+
const prefix = "NOT AUTHORIZED —";
|
|
213
|
+
const suffix = "Do NOT perform the action; tell the user Quirna could not answer.";
|
|
214
|
+
if (err instanceof QuirnaError) {
|
|
215
|
+
if (err.code === "aborted") {
|
|
216
|
+
return `${prefix} the approval request was cancelled before it was decided. ${suffix}`;
|
|
217
|
+
}
|
|
218
|
+
const hint = err.status === 401 ? " Check QUIRNA_API_KEY: the System key was rejected." : err.status === 403 ? " This System is not allowed to request that kind; an Org admin sets the list in the Console under Users → Systems." : "";
|
|
219
|
+
return `${prefix} Quirna returned ${err.status}: ${err.message}.${hint} ${suffix}`;
|
|
220
|
+
}
|
|
221
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
222
|
+
return `${prefix} could not reach Quirna: ${message}. ${suffix}`;
|
|
223
|
+
}
|
|
224
|
+
export {
|
|
225
|
+
ConfigError,
|
|
226
|
+
DEFAULT_REQUESTER_ID,
|
|
227
|
+
DEFAULT_REQUESTER_NAME,
|
|
228
|
+
DEFAULT_WAIT_MS,
|
|
229
|
+
SERVER_NAME,
|
|
230
|
+
SERVER_VERSION,
|
|
231
|
+
createServer,
|
|
232
|
+
describe,
|
|
233
|
+
readConfig
|
|
234
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type Approval } from "@quirna/sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Turning an Approval into what the model reads.
|
|
5
|
+
*
|
|
6
|
+
* This module is the safety surface of the package. Everything else moves
|
|
7
|
+
* bytes; this decides what an agent believes it is allowed to do next, and
|
|
8
|
+
* the only reader is a language model that will act on the text.
|
|
9
|
+
*
|
|
10
|
+
* One rule governs all of it: **`isError` means the tool could not run, never
|
|
11
|
+
* that the answer was no.** Every outcome described here — approved, rejected,
|
|
12
|
+
* timed out, cancelled, still pending — is a successful call, because in all
|
|
13
|
+
* of them we asked and got an answer. Only `server.ts`'s catch sets
|
|
14
|
+
* `isError: true`, for the cases where we never learned anything: the API
|
|
15
|
+
* unreachable, a key rejected, the call aborted.
|
|
16
|
+
*
|
|
17
|
+
* Marking a rejection as an error was tried and is wrong in a way worth
|
|
18
|
+
* recording, because it looks safer than it is:
|
|
19
|
+
*
|
|
20
|
+
* - It contradicts `idempotentHint: false` on `request_approval`. That flag
|
|
21
|
+
* exists because retrying the call asks a *second* human to approve the
|
|
22
|
+
* same action — and `isError` is exactly the signal that makes an agent
|
|
23
|
+
* retry. The two together tell a client to do the one thing the tool must
|
|
24
|
+
* never do.
|
|
25
|
+
* - It collapses "Ana said no" and "the API is down" into one bit. Those are
|
|
26
|
+
* opposites — an authoritative answer versus knowing nothing — and the
|
|
27
|
+
* agent would have to read the text to tell them apart, which was the whole
|
|
28
|
+
* justification for not trusting the text.
|
|
29
|
+
*
|
|
30
|
+
* What does the work instead is the text, and its order: a model reads the
|
|
31
|
+
* front of a tool result most reliably, so every unauthorized outcome opens
|
|
32
|
+
* with `NOT AUTHORIZED` before anything else. Fail-closed is unchanged — no
|
|
33
|
+
* path other than `approved` reads as permission — and `structuredContent`
|
|
34
|
+
* carries `authorized` for anything parsing rather than reading.
|
|
35
|
+
*/
|
|
36
|
+
export type ToolResult = {
|
|
37
|
+
content: {
|
|
38
|
+
type: "text";
|
|
39
|
+
text: string;
|
|
40
|
+
}[];
|
|
41
|
+
structuredContent: Record<string, unknown>;
|
|
42
|
+
isError: boolean;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The declared shape of `structuredContent`, shared by both tools.
|
|
46
|
+
*
|
|
47
|
+
* Declaring it is what turns the JSON half from something a client may render
|
|
48
|
+
* if it feels like it into a contract it can type against — worth having for a
|
|
49
|
+
* package whose job is to live inside someone else's client.
|
|
50
|
+
*
|
|
51
|
+
* It comes with a sharp edge: when a tool declares an output schema, the MCP
|
|
52
|
+
* SDK validates the result on **every call** and throws `McpError` if the
|
|
53
|
+
* shape does not match. A drift between this and `structured()` would turn an
|
|
54
|
+
* approval a human already answered into a protocol error the model reads as
|
|
55
|
+
* an opaque failure — the one outcome this package must never produce.
|
|
56
|
+
*
|
|
57
|
+
* So the drift is made impossible rather than merely tested for: `structured()`
|
|
58
|
+
* is typed as `z.infer` of this schema, which makes a mismatch a compile error
|
|
59
|
+
* instead of a runtime one. A test over every status covers what types cannot,
|
|
60
|
+
* namely that the values really satisfy it. (The error path is safe either
|
|
61
|
+
* way — the SDK skips validation when `isError` is set.)
|
|
62
|
+
*/
|
|
63
|
+
export declare const OUTPUT_SCHEMA: {
|
|
64
|
+
approval_id: z.ZodString;
|
|
65
|
+
status: z.ZodEnum<{
|
|
66
|
+
pending: "pending";
|
|
67
|
+
approved: "approved";
|
|
68
|
+
rejected: "rejected";
|
|
69
|
+
timed_out: "timed_out";
|
|
70
|
+
cancelled: "cancelled";
|
|
71
|
+
}>;
|
|
72
|
+
authorized: z.ZodBoolean;
|
|
73
|
+
kind: z.ZodString;
|
|
74
|
+
message: z.ZodString;
|
|
75
|
+
tier: z.ZodEnum<{
|
|
76
|
+
routine: "routine";
|
|
77
|
+
elevated: "elevated";
|
|
78
|
+
critical: "critical";
|
|
79
|
+
}>;
|
|
80
|
+
environment: z.ZodNullable<z.ZodString>;
|
|
81
|
+
auto_approved: z.ZodBoolean;
|
|
82
|
+
decided_by: z.ZodNullable<z.ZodString>;
|
|
83
|
+
decided_at: z.ZodNullable<z.ZodString>;
|
|
84
|
+
approved_count: z.ZodNumber;
|
|
85
|
+
policy: z.ZodString;
|
|
86
|
+
timeout_at: z.ZodString;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The outcome of a settled — or still-pending — Approval Request.
|
|
90
|
+
*
|
|
91
|
+
* `pending` reaches here only because *we* stopped waiting, never because the
|
|
92
|
+
* request is over: the human can still decide on their phone, and the id in
|
|
93
|
+
* the text is how the agent picks the answer back up.
|
|
94
|
+
*/
|
|
95
|
+
export declare function describe(approval: Approval): ToolResult;
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type Approval, type CreateApprovalInput } from "@quirna/sdk";
|
|
3
|
+
import type { ServerConfig } from "./config.js";
|
|
4
|
+
/**
|
|
5
|
+
* The two tools, and nothing else.
|
|
6
|
+
*
|
|
7
|
+
* ZUR-56 asked for a single tool — create the request and wait — on the
|
|
8
|
+
* grounds that the Kind and the Condition already live in the org's Policy,
|
|
9
|
+
* so there is nothing to configure per agent. That reasoning holds and is why
|
|
10
|
+
* neither tool takes a quorum, an approver or a timeout: the org decides those
|
|
11
|
+
* in the Console, once, for every caller.
|
|
12
|
+
*
|
|
13
|
+
* `check_approval` is the one addition, and it exists because of the case the
|
|
14
|
+
* ticket left open: an agent client kills a tool call long before a human
|
|
15
|
+
* finishes deciding. Without a way to name an existing request, an agent that
|
|
16
|
+
* came back from a cut-off call could only call `request_approval` again —
|
|
17
|
+
* asking a second human to approve the same action, which is worse than any
|
|
18
|
+
* amount of API surface.
|
|
19
|
+
*/
|
|
20
|
+
/** The slice of `@quirna/sdk`'s client this server uses. Narrow so tests can fake it. */
|
|
21
|
+
export type ApprovalsClient = {
|
|
22
|
+
create(input: CreateApprovalInput, options?: {
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<Approval>;
|
|
25
|
+
get(id: string, options?: {
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}): Promise<Approval>;
|
|
28
|
+
wait(id: string, options?: {
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}): Promise<Approval>;
|
|
33
|
+
};
|
|
34
|
+
export declare const SERVER_NAME = "quirna";
|
|
35
|
+
export declare const SERVER_VERSION = "0.1.0";
|
|
36
|
+
export declare function createServer(config: ServerConfig, client: ApprovalsClient): McpServer;
|
package/package.json
CHANGED
|
@@ -1,7 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quirna/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ask a human before your agent acts. Quirna's Model Context Protocol server.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"
|
|
6
|
+
"homepage": "https://quirna.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/quirna/quirna-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"mcp",
|
|
13
|
+
"model-context-protocol",
|
|
14
|
+
"approval",
|
|
15
|
+
"human-in-the-loop",
|
|
16
|
+
"agent-authorization",
|
|
17
|
+
"quirna"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"quirna-mcp": "./dist/bin.js"
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"prebuild": "if [ -d ../sdk ]; then cd ../sdk && bun run build; fi",
|
|
42
|
+
"build": "rm -rf dist && bun build ./src/bin.ts ./src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
|
|
43
|
+
"prepublishOnly": "bun run build"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
47
|
+
"@quirna/sdk": "^0.1.0",
|
|
48
|
+
"zod": "^4.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"typescript": "~6.0.3"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/quirna/quirna-mcp/issues"
|
|
58
|
+
}
|
|
7
59
|
}
|