norn-cli 2.9.8 → 3.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/CLAUDE.md +68 -0
- package/NOW.md +170 -21
- package/README.md +226 -2
- package/demos/agent-workbench/README.md +156 -0
- package/demos/agent-workbench/agents.nornagent +87 -0
- package/demos/agent-workbench/contracts/domain-answer.schema.json +28 -0
- package/demos/agent-workbench/contracts/domain-question.schema.json +34 -0
- package/demos/agent-workbench/contracts/router-verdict.schema.json +43 -0
- package/demos/agent-workbench/contracts/ticket.schema.json +29 -0
- package/demos/agent-workbench/contracts/verdict.schema.json +48 -0
- package/demos/agent-workbench/fake-openai-server.js +128 -0
- package/demos/agent-workbench/fixtures/aligned-ticket.json +10 -0
- package/demos/agent-workbench/fixtures/cross-domain-ticket.json +10 -0
- package/demos/agent-workbench/fixtures/invalid-output-ticket.json +8 -0
- package/demos/agent-workbench/norn.config.json +15 -0
- package/demos/agent-workbench/prompts/ticket-router.md +11 -0
- package/demos/agent-workbench/workbench.norn +32 -0
- package/demos/mcp-ticket-testing/README.md +114 -0
- package/demos/mcp-ticket-testing/agents.nornagent +77 -0
- package/demos/mcp-ticket-testing/contracts/test-run.schema.json +31 -0
- package/demos/mcp-ticket-testing/expectations/proj-142.md +12 -0
- package/demos/mcp-ticket-testing/fixtures/proj-142.json +13 -0
- package/demos/mcp-ticket-testing/prompts/backend-tester.md +12 -0
- package/demos/mcp-ticket-testing/prompts/frontend-tester.md +14 -0
- package/demos/mcp-ticket-testing/prompts/reporter.md +10 -0
- package/demos/mcp-ticket-testing/servers/browser-server.js +133 -0
- package/demos/mcp-ticket-testing/servers/house-server.js +125 -0
- package/demos/mcp-ticket-testing/tickets.norn +32 -0
- package/dist/cli.js +185429 -111460
- package/package.json +71 -4
- package/playground/ai.norn +15 -0
- package/playground/ai_orchastration.nornagent +29 -0
- package/playground/knowedge_base/nexus_system_prompt.md +1 -0
- package/schemas/norn.config.schema.json +223 -0
- package/CHANGELOG.md +0 -1506
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Ticket testing with declared MCP servers
|
|
2
|
+
|
|
3
|
+
An agent graph that **does** something: a ticket arrives, one agent tests the product through a
|
|
4
|
+
browser, another tests it through the team's own API, a third writes the results up, and a
|
|
5
|
+
`judge` rules on whether the expected test cases were actually covered.
|
|
6
|
+
|
|
7
|
+
Everything it connects to is declared in [`agents.nornagent`](./agents.nornagent), beside the
|
|
8
|
+
model. There is **no `norn.config.json` in this folder** — the servers, their transports, and
|
|
9
|
+
their session lifetimes are all in the file that uses them.
|
|
10
|
+
|
|
11
|
+
## What to look at
|
|
12
|
+
|
|
13
|
+
```nornagent
|
|
14
|
+
mcp Browser
|
|
15
|
+
transport stdio
|
|
16
|
+
command node ./servers/browser-server.js
|
|
17
|
+
session agent
|
|
18
|
+
end mcp
|
|
19
|
+
|
|
20
|
+
agent FrontendTester
|
|
21
|
+
mcp Browser # the run of the browser
|
|
22
|
+
tools House.getFixtureUser, House.resetTenant # only what it needs from ours
|
|
23
|
+
end agent
|
|
24
|
+
|
|
25
|
+
agent BackendTester
|
|
26
|
+
tools House.callEndpoint, House.readAuditLog, House.queryDb
|
|
27
|
+
end agent
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Three things this shows, and they are the point of the whole slice:
|
|
31
|
+
|
|
32
|
+
- **Two agents, one custom server, different tools each.** `BackendTester` gets three named
|
|
33
|
+
tools from `House` and cannot touch the browser at all. The access boundary is visible per
|
|
34
|
+
agent, in one file, in git.
|
|
35
|
+
- **`mcp Browser` and `tools House.callEndpoint` are visibly different statements.** Dropping
|
|
36
|
+
four characters must not silently widen access from one tool to twenty, so there is no
|
|
37
|
+
short form that means "all of it".
|
|
38
|
+
- **`session agent` on the browser.** Each agent invocation gets its own browser, closed when
|
|
39
|
+
that hop ends. The default, `session run`, shares one browser across every agent in the
|
|
40
|
+
sequence — right for a continuous flow, and wrong the moment a second agent is granted the
|
|
41
|
+
same server: it would inherit the first one's login and could pass a test it never
|
|
42
|
+
authenticated for, which is a false green and worse than a false red. Only `FrontendTester`
|
|
43
|
+
reaches the browser in this graph, so this is the lifetime worth authoring by default rather
|
|
44
|
+
than a bleed this demo would actually suffer.
|
|
45
|
+
|
|
46
|
+
## Run it
|
|
47
|
+
|
|
48
|
+
The servers are deterministic and ship with the demo, so nothing needs installing or starting.
|
|
49
|
+
Prove they are reachable **without spending anything on a model**:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm run compile
|
|
53
|
+
node ./dist/cli.js demos/mcp-ticket-testing/tickets.norn --sequence ServersUp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That resolves the same aliases the agents use, so a failure points at the server rather than at
|
|
57
|
+
an agent. When you are building a server of your own, this is the loop to develop against.
|
|
58
|
+
|
|
59
|
+
Then the real thing, which does need a model:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
export OPENAI_API="your-key"
|
|
63
|
+
node ./dist/cli.js demos/mcp-ticket-testing/tickets.norn --sequence TicketPROJ142
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The variable name is not a convention Norn assumes — it is written in the model block as
|
|
67
|
+
`apiKey {{$env.OPENAI_API}}`, so any name works as long as the file and the environment
|
|
68
|
+
agree. A `.nornenv` value wins over the process environment; add `--env <name>` to select one.
|
|
69
|
+
|
|
70
|
+
## The browser here is a stand-in
|
|
71
|
+
|
|
72
|
+
[`servers/browser-server.js`](./servers/browser-server.js) is a deterministic scripted site, not
|
|
73
|
+
a real browser, so this demo runs anywhere `node` does — no browser download, no server to keep
|
|
74
|
+
alive. Its declaration is the **same shape** a real Playwright MCP block takes, and both real
|
|
75
|
+
forms are written out in the comments at the top of
|
|
76
|
+
[`agents.nornagent`](./agents.nornagent):
|
|
77
|
+
|
|
78
|
+
```nornagent
|
|
79
|
+
mcp Browser
|
|
80
|
+
transport stdio
|
|
81
|
+
command npx playwright-mcp --headless --isolated
|
|
82
|
+
session agent
|
|
83
|
+
end mcp
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For CI with more than one sequence, run Playwright MCP as a service and dial it instead — that
|
|
87
|
+
is what `transport http` is for. Norn never starts an http server; it only connects to one, and
|
|
88
|
+
a refused connection fails before any model spend, naming the URL it tried.
|
|
89
|
+
|
|
90
|
+
## The judge, and the CI story
|
|
91
|
+
|
|
92
|
+
The last line of `TicketPROJ142` is the part that makes this a test rather than a demo:
|
|
93
|
+
|
|
94
|
+
```norn
|
|
95
|
+
judge report with Reviewer expects file expectations/proj-142.md
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Every expectation in [`expectations/proj-142.md`](./expectations/proj-142.md) must be met;
|
|
99
|
+
testing *more* than the list is not a failure. The judge is never asked for an overall verdict —
|
|
100
|
+
it rules on each expectation and quotes its evidence, and Norn computes the result.
|
|
101
|
+
|
|
102
|
+
Note the comment lines in that file. A line of prose left uncommented there **is** an
|
|
103
|
+
expectation, which is both Norn's comment rule and what makes Markdown headings work.
|
|
104
|
+
|
|
105
|
+
A live run records itself under `.norn-cache/runs/`. Replay it with no servers running at all:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
node ./dist/cli.js replay .norn-cache/runs/<recording>.json
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
That is the CI answer for any expensive server: run it live where the servers exist, and gate
|
|
112
|
+
pull requests on the replay — no browser, no API, no model, and the graph and contracts still
|
|
113
|
+
checked. The recording keeps the tool names each granted server advertised, so a server that
|
|
114
|
+
quietly gains or loses a tool is reported the next time a live run sees it.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Ticket testing — declared MCP servers, granted per agent.
|
|
2
|
+
#
|
|
3
|
+
# Every server this graph uses is declared here, beside the model, so the file says exactly
|
|
4
|
+
# what it connects to. Nothing is left to a norn.config.json the reader cannot see.
|
|
5
|
+
|
|
6
|
+
model Workbench
|
|
7
|
+
provider openai
|
|
8
|
+
name gpt-4o
|
|
9
|
+
apiKey {{$env.OPENAI_API}}
|
|
10
|
+
end model
|
|
11
|
+
|
|
12
|
+
# The browser. This demo ships a deterministic stand-in so it runs anywhere `node` does —
|
|
13
|
+
# the declaration is the same shape a real Playwright MCP server takes:
|
|
14
|
+
#
|
|
15
|
+
# mcp Browser
|
|
16
|
+
# transport stdio
|
|
17
|
+
# command npx playwright-mcp --headless --isolated
|
|
18
|
+
# session agent
|
|
19
|
+
# end mcp
|
|
20
|
+
#
|
|
21
|
+
# ...or, for a long-lived server you started yourself (a CI job with many sequences):
|
|
22
|
+
#
|
|
23
|
+
# mcp Browser
|
|
24
|
+
# transport http
|
|
25
|
+
# url {{$env.BROWSER_MCP_URL}} # e.g. http://localhost:8931/mcp
|
|
26
|
+
# timeout 120000 # browser work is slow; the default is not for this
|
|
27
|
+
# end mcp
|
|
28
|
+
mcp Browser
|
|
29
|
+
transport stdio
|
|
30
|
+
command node ./servers/browser-server.js
|
|
31
|
+
# One browser per agent invocation, closed when that hop ends. The default, `session run`,
|
|
32
|
+
# would share one browser across every agent in the sequence — which is what you want for a
|
|
33
|
+
# continuous flow, and what you do not want the moment a second agent is granted this server:
|
|
34
|
+
# it would inherit the first one's login and could pass a test it never authenticated for.
|
|
35
|
+
# Only FrontendTester reaches the browser here, so this is the safe default to author rather
|
|
36
|
+
# than a bleed this graph would actually suffer.
|
|
37
|
+
session agent
|
|
38
|
+
end mcp
|
|
39
|
+
|
|
40
|
+
# The team's own server: fixtures, endpoints, the audit log, the database.
|
|
41
|
+
mcp House
|
|
42
|
+
transport stdio
|
|
43
|
+
command node ./servers/house-server.js
|
|
44
|
+
end mcp
|
|
45
|
+
|
|
46
|
+
agent FrontendTester
|
|
47
|
+
model Workbench
|
|
48
|
+
# The run of the browser: this agent drives the site, and which tools that needs is not
|
|
49
|
+
# something the author should have to enumerate.
|
|
50
|
+
mcp Browser
|
|
51
|
+
# From our own server it gets only what it needs to set up — and cannot reach the
|
|
52
|
+
# database or the audit log at all.
|
|
53
|
+
tools House.getFixtureUser, House.resetTenant
|
|
54
|
+
returns contracts/test-run.schema.json
|
|
55
|
+
system file prompts/frontend-tester.md
|
|
56
|
+
end agent
|
|
57
|
+
|
|
58
|
+
agent BackendTester
|
|
59
|
+
model Workbench
|
|
60
|
+
# The same server, narrowed differently. Both grants are visible per agent, in one file.
|
|
61
|
+
tools House.callEndpoint, House.readAuditLog, House.queryDb
|
|
62
|
+
returns contracts/test-run.schema.json
|
|
63
|
+
system file prompts/backend-tester.md
|
|
64
|
+
end agent
|
|
65
|
+
|
|
66
|
+
agent Reporter
|
|
67
|
+
model Workbench
|
|
68
|
+
system file prompts/reporter.md
|
|
69
|
+
end agent
|
|
70
|
+
|
|
71
|
+
agent Reviewer
|
|
72
|
+
model Workbench
|
|
73
|
+
system "
|
|
74
|
+
You are reviewing a QA report against a checklist of expected test cases.
|
|
75
|
+
Rule on each expectation independently and quote the report as evidence.
|
|
76
|
+
"
|
|
77
|
+
end agent
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"additionalProperties": false,
|
|
4
|
+
"properties": {
|
|
5
|
+
"surface": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"description": "Which side of the product was exercised: frontend or backend."
|
|
8
|
+
},
|
|
9
|
+
"cases": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"description": "One entry per test case actually carried out.",
|
|
12
|
+
"items": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {
|
|
16
|
+
"name": { "type": "string" },
|
|
17
|
+
"steps": { "type": "array", "items": { "type": "string" } },
|
|
18
|
+
"outcome": { "type": "string", "enum": ["passed", "failed"] },
|
|
19
|
+
"evidence": { "type": "string" }
|
|
20
|
+
},
|
|
21
|
+
"required": ["name", "steps", "outcome", "evidence"]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"findings": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"description": "Anything that looked wrong. Empty when nothing did.",
|
|
27
|
+
"items": { "type": "string" }
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"required": ["surface", "cases", "findings"]
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Expected test cases for PROJ-142
|
|
2
|
+
|
|
3
|
+
# Each line below is one expectation. All must be met; testing more than this is not a
|
|
4
|
+
# failure. Lines starting with # are comments — prose left uncommented here would itself
|
|
5
|
+
# become an expectation, which is both Norn's comment rule and what makes Markdown work.
|
|
6
|
+
|
|
7
|
+
- A test covers that a signed-out visitor opening /orders is sent to the sign-in page
|
|
8
|
+
- A test covers signing in and seeing the orders list
|
|
9
|
+
- A test covers opening order A-1001 and reading its total
|
|
10
|
+
- A test covers GET /orders/A-1001 returning 200 with the order
|
|
11
|
+
- A test covers GET /orders/A-9999 returning 404
|
|
12
|
+
- A test confirms endpoint calls reach the audit log
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "PROJ-142",
|
|
3
|
+
"title": "Order detail page and order lookup endpoint",
|
|
4
|
+
"description": "A signed-in customer can open an order from the orders list and see its total. The API must return the order for a valid id and 404 for an unknown one.",
|
|
5
|
+
"acceptanceCriteria": [
|
|
6
|
+
"A signed-out visitor who opens /orders is sent to the sign-in page",
|
|
7
|
+
"After signing in, the orders list shows the customer's orders",
|
|
8
|
+
"Opening order A-1001 shows its total",
|
|
9
|
+
"GET /orders/A-1001 returns 200 with the order",
|
|
10
|
+
"GET /orders/A-9999 returns 404",
|
|
11
|
+
"Every endpoint call is written to the audit log"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
You test the backend of a web product through its team's own MCP server.
|
|
2
|
+
|
|
3
|
+
Work only from the ticket you are given. For each acceptance criterion that is about an API,
|
|
4
|
+
a database row, or an audit entry, exercise it with the tools you have and record what came
|
|
5
|
+
back — never what you assume would come back.
|
|
6
|
+
|
|
7
|
+
`callEndpoint` calls the API. `queryDb` reads seeded tables. `readAuditLog` returns what this
|
|
8
|
+
run wrote. You have no browser, and you must not claim to have used one.
|
|
9
|
+
|
|
10
|
+
Return the required JSON only: one case per criterion you exercised, the steps you took, the
|
|
11
|
+
outcome, and a short quote of the evidence you saw. Put anything that looked wrong in
|
|
12
|
+
`findings`. Set `surface` to "backend".
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
You test the front end of a web product by driving a browser through an MCP server.
|
|
2
|
+
|
|
3
|
+
Work only from the ticket you are given. For each acceptance criterion that is about what a
|
|
4
|
+
person sees or does in the browser, carry out the steps yourself with the browser tools and
|
|
5
|
+
record what actually happened — never what you assume would happen.
|
|
6
|
+
|
|
7
|
+
The browser starts signed out. `getFixtureUser` gives you the test account; `resetTenant`
|
|
8
|
+
puts the tenant back to its seeded state before you start.
|
|
9
|
+
|
|
10
|
+
Snapshots are text. Read the elements listed in a snapshot rather than guessing at a page.
|
|
11
|
+
|
|
12
|
+
Return the required JSON only: one case per criterion you exercised, the steps you took, the
|
|
13
|
+
outcome, and a short quote of the evidence you saw. Put anything that looked wrong in
|
|
14
|
+
`findings`. Set `surface` to "frontend".
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
You turn two test runs into one short QA report for a ticket.
|
|
2
|
+
|
|
3
|
+
You are given the frontend run and the backend run. Write, in plain prose:
|
|
4
|
+
|
|
5
|
+
1. Which test cases were carried out, naming each one.
|
|
6
|
+
2. What the evidence was for each.
|
|
7
|
+
3. Any findings, or a single sentence saying there were none.
|
|
8
|
+
|
|
9
|
+
Report only what the runs contain. Do not invent a case that nobody ran, and do not soften a
|
|
10
|
+
failure. Someone will check this report against the list of expected test cases.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A deterministic stand-in for Playwright MCP.
|
|
5
|
+
*
|
|
6
|
+
* The `mcp Browser` block in `agents.nornagent` is the *same shape* a real Playwright MCP
|
|
7
|
+
* declaration takes — see the commented block there — so this demo runs anywhere `node` does,
|
|
8
|
+
* with no browser download, while showing exactly what granting a browser looks like.
|
|
9
|
+
*
|
|
10
|
+
* It answers from a tiny scripted site, and its snapshots are text, which is what a browser
|
|
11
|
+
* agent should be working from anyway: image content never reaches a model's prompt.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
15
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
16
|
+
const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
17
|
+
|
|
18
|
+
const PAGES = {
|
|
19
|
+
'/login': {
|
|
20
|
+
title: 'Sign in',
|
|
21
|
+
elements: ['textbox "Email"', 'textbox "Password"', 'button "Sign in"']
|
|
22
|
+
},
|
|
23
|
+
'/orders': {
|
|
24
|
+
title: 'Your orders',
|
|
25
|
+
elements: ['link "A-1001"', 'link "A-1002"', 'button "New order"']
|
|
26
|
+
},
|
|
27
|
+
'/orders/A-1001': {
|
|
28
|
+
title: 'Order A-1001',
|
|
29
|
+
elements: ['heading "Order A-1001"', 'text "Total: 42.50"', 'button "Cancel order"']
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const state = { url: '/login', signedIn: false, filled: {} };
|
|
34
|
+
|
|
35
|
+
const TOOLS = [
|
|
36
|
+
{
|
|
37
|
+
name: 'browser_navigate',
|
|
38
|
+
description: 'Navigate to a path on the site under test.',
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
properties: { path: { type: 'string', description: 'Path to open, e.g. /orders.' } },
|
|
43
|
+
required: ['path']
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'browser_fill',
|
|
48
|
+
description: 'Fill a named field on the current page.',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
properties: {
|
|
53
|
+
field: { type: 'string', description: 'Field label, e.g. Email.' },
|
|
54
|
+
value: { type: 'string', description: 'Value to type.' }
|
|
55
|
+
},
|
|
56
|
+
required: ['field', 'value']
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'browser_click',
|
|
61
|
+
description: 'Click a named control on the current page.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
properties: { control: { type: 'string', description: 'Control label, e.g. Sign in.' } },
|
|
66
|
+
required: ['control']
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'browser_snapshot',
|
|
71
|
+
description: 'Return a text accessibility snapshot of the current page.',
|
|
72
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} }
|
|
73
|
+
}
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function structured(value) {
|
|
77
|
+
return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function snapshot() {
|
|
81
|
+
const page = PAGES[state.url] || { title: 'Not found', elements: [] };
|
|
82
|
+
return {
|
|
83
|
+
url: state.url,
|
|
84
|
+
title: page.title,
|
|
85
|
+
signedIn: state.signedIn,
|
|
86
|
+
elements: page.elements
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
const server = new Server(
|
|
92
|
+
{ name: 'norn-demo-browser', version: '1.0.0' },
|
|
93
|
+
{ capabilities: { tools: {} } }
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
97
|
+
|
|
98
|
+
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
99
|
+
const args = request.params.arguments || {};
|
|
100
|
+
switch (request.params.name) {
|
|
101
|
+
case 'browser_navigate':
|
|
102
|
+
state.url = String(args.path || '/');
|
|
103
|
+
if (state.url !== '/login' && !state.signedIn) {
|
|
104
|
+
// The whole point of a session: an unauthenticated visit lands on login.
|
|
105
|
+
state.url = '/login';
|
|
106
|
+
}
|
|
107
|
+
return structured(snapshot());
|
|
108
|
+
case 'browser_fill':
|
|
109
|
+
state.filled[String(args.field)] = String(args.value);
|
|
110
|
+
return structured({ filled: args.field, ...snapshot() });
|
|
111
|
+
case 'browser_click':
|
|
112
|
+
if (String(args.control) === 'Sign in' && state.filled.Email && state.filled.Password) {
|
|
113
|
+
state.signedIn = true;
|
|
114
|
+
state.url = '/orders';
|
|
115
|
+
}
|
|
116
|
+
return structured(snapshot());
|
|
117
|
+
case 'browser_snapshot':
|
|
118
|
+
return structured(snapshot());
|
|
119
|
+
default:
|
|
120
|
+
return {
|
|
121
|
+
isError: true,
|
|
122
|
+
content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await server.connect(new StdioServerTransport());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main().catch(error => {
|
|
131
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "House" — the custom MCP server a team writes for its own product.
|
|
5
|
+
*
|
|
6
|
+
* Deterministic on purpose: the demo is about declaring a server and granting it, not about
|
|
7
|
+
* what any particular tool does. Everything here is in-memory and reset per process.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
11
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
12
|
+
const { ListToolsRequestSchema, CallToolRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
13
|
+
|
|
14
|
+
const state = {
|
|
15
|
+
tenant: 'acme',
|
|
16
|
+
auditLog: [],
|
|
17
|
+
orders: [
|
|
18
|
+
{ id: 'A-1001', tenant: 'acme', total: 42.5, status: 'placed' },
|
|
19
|
+
{ id: 'A-1002', tenant: 'acme', total: 18.0, status: 'shipped' }
|
|
20
|
+
]
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TOOLS = [
|
|
24
|
+
{
|
|
25
|
+
name: 'getFixtureUser',
|
|
26
|
+
description: 'Return the seeded test user for the current tenant.',
|
|
27
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} }
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'resetTenant',
|
|
31
|
+
description: 'Reset the tenant to its seeded state before a test run.',
|
|
32
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} }
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'callEndpoint',
|
|
36
|
+
description: 'Call a backend endpoint and return its status and body.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
properties: {
|
|
41
|
+
method: { type: 'string', description: 'HTTP method, e.g. GET or POST.' },
|
|
42
|
+
path: { type: 'string', description: 'Endpoint path, e.g. /orders/A-1001.' }
|
|
43
|
+
},
|
|
44
|
+
required: ['method', 'path']
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'readAuditLog',
|
|
49
|
+
description: 'Return the audit entries written during this run, oldest first.',
|
|
50
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} }
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'queryDb',
|
|
54
|
+
description: 'Read rows from a seeded table by name.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
properties: { table: { type: 'string', description: 'Table to read, e.g. orders.' } },
|
|
59
|
+
required: ['table']
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function structured(value) {
|
|
65
|
+
return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function callEndpoint(args) {
|
|
69
|
+
const path = String(args.path || '');
|
|
70
|
+
const method = String(args.method || 'GET').toUpperCase();
|
|
71
|
+
state.auditLog.push({ at: state.auditLog.length + 1, method, path });
|
|
72
|
+
|
|
73
|
+
const orderMatch = path.match(/^\/orders\/([A-Za-z0-9-]+)$/);
|
|
74
|
+
if (orderMatch) {
|
|
75
|
+
const order = state.orders.find(item => item.id === orderMatch[1]);
|
|
76
|
+
return order
|
|
77
|
+
? { status: 200, body: order }
|
|
78
|
+
: { status: 404, body: { error: 'order not found' } };
|
|
79
|
+
}
|
|
80
|
+
if (path === '/orders') {
|
|
81
|
+
return { status: 200, body: { orders: state.orders } };
|
|
82
|
+
}
|
|
83
|
+
return { status: 404, body: { error: 'no such endpoint' } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function main() {
|
|
87
|
+
const server = new Server(
|
|
88
|
+
{ name: 'norn-demo-house', version: '1.0.0' },
|
|
89
|
+
{ capabilities: { tools: {} } }
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
93
|
+
|
|
94
|
+
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
95
|
+
const args = request.params.arguments || {};
|
|
96
|
+
switch (request.params.name) {
|
|
97
|
+
case 'getFixtureUser':
|
|
98
|
+
return structured({ id: 'u-1', email: 'tester@example.com', tenant: state.tenant });
|
|
99
|
+
case 'resetTenant':
|
|
100
|
+
state.auditLog = [];
|
|
101
|
+
return structured({ tenant: state.tenant, reset: true });
|
|
102
|
+
case 'callEndpoint':
|
|
103
|
+
return structured(callEndpoint(args));
|
|
104
|
+
case 'readAuditLog':
|
|
105
|
+
return structured({ entries: state.auditLog });
|
|
106
|
+
case 'queryDb':
|
|
107
|
+
return structured({
|
|
108
|
+
table: args.table,
|
|
109
|
+
rows: args.table === 'orders' ? state.orders : []
|
|
110
|
+
});
|
|
111
|
+
default:
|
|
112
|
+
return {
|
|
113
|
+
isError: true,
|
|
114
|
+
content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }]
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await server.connect(new StdioServerTransport());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
main().catch(error => {
|
|
123
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import "./agents.nornagent"
|
|
2
|
+
|
|
3
|
+
# Prove both servers are reachable without spending anything on a model.
|
|
4
|
+
# These steps resolve the same aliases the agents use, so a failure here points at the server
|
|
5
|
+
# rather than at an agent — this is the loop to develop a server against.
|
|
6
|
+
test sequence ServersUp
|
|
7
|
+
var browserTools = run mcp list Browser
|
|
8
|
+
assert browserTools.length > 0
|
|
9
|
+
|
|
10
|
+
var houseTools = run mcp list House
|
|
11
|
+
assert houseTools.length > 0
|
|
12
|
+
|
|
13
|
+
var user = run mcp call House getFixtureUser()
|
|
14
|
+
assert user.structuredContent.email == "tester@example.com"
|
|
15
|
+
end sequence
|
|
16
|
+
|
|
17
|
+
# The application this whole slice was designed against: a ticket arrives, two agents test it
|
|
18
|
+
# from different sides with different tools, a third writes it up, and a judge rules on whether
|
|
19
|
+
# the expected test cases were actually covered.
|
|
20
|
+
test sequence TicketPROJ142
|
|
21
|
+
var ticket = run readJson "./fixtures/proj-142.json"
|
|
22
|
+
|
|
23
|
+
var frontend = run FrontendTester ticket
|
|
24
|
+
var backend = run BackendTester ticket
|
|
25
|
+
# An agent's input is a variable or a string — there is no object-literal form — so the two
|
|
26
|
+
# runs are interpolated into one prompt.
|
|
27
|
+
var report = run Reporter "Frontend run:\n{{frontend.body}}\n\nBackend run:\n{{backend.body}}"
|
|
28
|
+
|
|
29
|
+
print "QA report" | "{{report.text}}"
|
|
30
|
+
|
|
31
|
+
judge report with Reviewer expects file expectations/proj-142.md
|
|
32
|
+
end sequence
|