spl-lang 0.1.1 → 0.3.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/README.md CHANGED
@@ -1,147 +1,91 @@
1
- # spl-lang: Semantic Prompt Language interpreter
1
+ # spl-lang 0.3
2
2
 
3
- A Python-like language where LLM calls are part of the syntax. Everything outside LLM calls is deterministic; LLM
4
- calls go through the GitHub Copilot SDK. See [../LANG-PRD.md](../LANG-PRD.md) for the full spec,
5
- [docs/syntax.ebnf](docs/syntax.ebnf) for the grammar and the [documentation site](../docs/index.html) (open it in a
6
- browser, or publish `docs/` with GitHub Pages) for guides and reference.
3
+ The Semantic Prompt Language interpreter. Requires Node.js 20+.
7
4
 
8
- ## Run
5
+ See the [project guide](../README.md), [language specification](../LANG-PRD.md),
6
+ [EBNF](docs/syntax.ebnf), and [documentation site](../docs/index.html).
9
7
 
10
- ```
11
- npm install && npm run build
12
- node dist/cli.js login # once: authenticate with GitHub Copilot
13
- node dist/cli.js examples/live.spl
14
- npx file:./lang examples/live.spl # npx from a checkout (or `npx spl-lang` once published)
15
- ```
16
-
17
- Requires Node 20+ and an authenticated GitHub Copilot.
8
+ ## Run from a checkout
18
9
 
10
+ ```sh
11
+ npm install
12
+ node dist/cli.js login
13
+ node dist/cli.js examples/hi.spl
19
14
  ```
20
- spl <script.spl> [--log-level error|info|debug] [--silent] [--model <id>]
21
- [--store <file.json> | --store-module <module>]
22
- [--timeout <s>] [--cwd <dir>] [--check]
23
- spl login # GitHub device flow: prints a code, you confirm it in the browser
24
- spl logout # removes the credentials saved by `spl login`
25
- ```
26
-
27
- * stdout: the value of `success` or a top-level `return` (strings raw, everything else as JSON) or the failure payload.
28
- * stderr: logs, including model and reasoning effort of every LLM call.
29
- * Exit code: `0` success, `1` uncaught failure, `2` syntax/usage error.
30
-
31
- `spl login` does nothing if the runtime is already authenticated (an earlier login, a Copilot CLI / `gh` login or
32
- `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`). Otherwise it runs the device flow and saves the token to
33
- `~/.spl/credentials.json` (override the folder with `SPL_HOME`); scripts use that token automatically. A script that
34
- needs an LLM and finds no credentials fails with `llm_error` and tells you to run `spl login`.
35
-
36
- ## Language at a glance
37
15
 
16
+ `npm install` runs the TypeScript build. Installed packages expose `spl` and `spl-lang`.
17
+ Authentication is needed only for live generation and explicit agents; raw tools and replay
18
+ work without Copilot. Existing Copilot/CLI credentials and supported GitHub token environment
19
+ variables are reused. `spl login` otherwise runs device authentication and saves credentials
20
+ under `~/.spl` (override with `SPL_HOME`); `spl logout` removes those saved credentials.
21
+
22
+ ## Language contract
23
+
24
+ - **Exact tools:** `read_file(path)`, `write_file(path, content)`, `web_fetch(url)`,
25
+ `web_search(query)`, `shell(command)` never call a model.
26
+ - **Generation:** `?` and `think` have no tools or MCP access.
27
+ - **Agency:** `agent` explicitly enables model-directed runtime and configured MCP tools.
28
+ - **Validation:** expression-valued `schema` uses strict synchronous JSON Schema draft-07;
29
+ `fmt='json'` accepts any JSON value, `fmt='object'` requires an object/array.
30
+ Prose and code fences are not silently extracted.
31
+ - **Repairs:** `repairs=0..5` retries only malformed/invalid generation, not transport errors
32
+ or external actions. Agents require `repairs=0`. Use `assert` for business rules.
33
+ - **Guidance:** `instruct`, `avoid`, and `len` influence prompts, not runtime guarantees.
34
+ - **Capabilities:** nested `restrict fs/net/exec/mcp` clauses intersect. Shell command
35
+ inspection is best effort; MCP servers are trusted services, not OS-sandboxed.
36
+ - **Bindings:** `let` declares/redeclares function/script locals; `x = ...` and `x += ...`
37
+ update existing locals. Every block shares that scope. Enclosing names are readable but
38
+ cannot be rebound by a function; shared collections remain mutable.
39
+
40
+ Common generation modifiers are `len`, `fmt`, `reasoning`, `show`, `schema`, `repairs`,
41
+ and `model`. Values are evaluated once, then validated before calling the model.
42
+ `think` and `agent` default to medium reasoning; `?` uses the model default.
43
+ CLI `--model` overrides per-call `model`, which overrides the header directive.
44
+
45
+ ## Record/replay
46
+
47
+ ```sh
48
+ node dist/cli.js examples/triage-issues.spl --record triage.jsonl
49
+ node dist/cli.js examples/triage-issues.spl --replay triage.jsonl --silent
38
50
  ```
39
- @{model='gpt-5.5'}
40
51
 
41
- def fib(n) {
42
- if n < 2 { return n }
43
- return fib(n - 1) + fib(n - 2)
44
- }
52
+ Record creates a new file and rejects agents. Replay performs exact, ordered request matching
53
+ without starting Copilot/MCP. Local reads are permitted; writes, shell, network, agents,
54
+ and persistent stores are blocked. Recordings include prompts and raw responses and must
55
+ be protected like logs. Mismatched, missing, or unused entries fail explicitly.
45
56
 
46
- let joke = ?{len='sentence', fmt='object'} 'A joke about programmers' # JSON validated and parsed
47
- let why = think{level='high', show=True} 'Why is {joke.setup} funny?'
57
+ ## CLI
48
58
 
49
- let color = None
50
- enforce 'Answer in English' {
51
- forbid 'profanity' { let color = ?{len='word'} 'Your favorite color?' } # results leave the block
52
- }
59
+ ```text
60
+ spl script.spl [--model id] [--mcp file.json] [--cwd dir] [--timeout seconds]
61
+ [--store file.json | --store-module module]
62
+ [--record file.jsonl | --replay file.jsonl]
63
+ [--log-level error|info|debug] [--silent] [--check]
64
+ ```
53
65
 
54
- { # a bare block is a nested scope
55
- let color = 'Red' # shadows the outer variable
56
- log(color)
57
- }
66
+ Results are printed on stdout, logs on stderr. Full prompts/responses and statement traces
67
+ appear at every non-silent log level. Exit codes are `0` success, `1` script failure,
68
+ `2` syntax/usage error, and `70` internal error. `--check` does not execute modifier expressions
69
+ or compile schemas. See [CLI and MCP configuration](../docs/cli.html).
58
70
 
59
- restrict './out' { # also: restrict net 'host,..' / restrict exec 'git,npm'
60
- let f = write_file{path='./out/a.txt', content='{fib(10)}'} 'Confirm'
61
- }
71
+ ## Tests and embedding
62
72
 
63
- try { assert fib(5) == 4 'wrong' } except err { log(err.reason) }
64
- success {'joke': joke, 'why': why, 'color': color}
73
+ ```sh
74
+ npm test
65
75
  ```
66
76
 
67
- Decisions worth knowing (the PRD leaves them open):
68
-
69
- * **Blocks use braces**, statements end at a newline.
70
- * **Every modification of a variable starts with `let`**: `let x = 1`, `let x += 1`, `let d['k'] = 2`, `let d.k = 3`.
71
- A bare `x = 1` is a syntax error.
72
- * **Scoping.** `let NAME = ...` binds in the current scope. Bare `{ ... }` blocks and function bodies open a new scope,
73
- so `let` there shadows an outer variable and the outer one is untouched. `if`/`elif`/`else`, `for`, `while`,
74
- `try`/`except`, `enforce`, `forbid` and `restrict` bodies share the enclosing scope, so `let` inside them updates the
75
- enclosing variable: this is how the answer of an LLM call leaves an `enforce`/`forbid`/`restrict` block. A function
76
- cannot rebind its caller's variables (use a list or dict to share state); names are read through all enclosing scopes.
77
- * **`return` exits immediately** from any scope: the innermost function, or the whole script when used at the top level
78
- (like `success`). `break`/`continue` still belong to loops.
79
- * **`?` is a prefix operator**: `?'a' + b` is `(?'a') + b`, `-?'n'` is `-(?'n')`.
80
- * **LLM commands have defaults and braces are optional**: `?'q'`, `think 'q'` (level `medium`), and tools without
81
- parameters, e.g. `read_file 'Read notes.txt and summarize it'`.
82
- * **Every string interpolates `{expr}`**. Write `{{`/`}}` or `\{`/`\}` for literal braces, or use a raw string
83
- `r'{"a": 1}'`. Values are substituted as is: strings raw, `True`/`False`/`None`, lists and dicts as JSON.
84
- * Tool calls (`read_file`, `write_file`, `web_fetch`, `web_search`, `shell`): when the script supplies every parameter
85
- the tool needs, the runtime runs it first (restriction-checked), and the output plus your instructions go to the LLM
86
- whose answer is the value. When a parameter is missing (`read_file 'summarize notes.txt'`,
87
- `write_file{path='a.txt'} 'write a haiku'`, `web_search 'latest Node.js LTS'`) the LLM calls the tool itself with the
88
- parameters you gave kept fixed. `web_search` queries DuckDuckGo and counts as network access for `restrict net`.
89
- * Builtins: `len str int float bool type list range abs round min max sum sorted reversed enumerate zip any all keys
90
- values items json_parse json_stringify log`, plus Python-style string/list/dict methods. `log(...)` writes to stderr.
91
- * Unknown modifiers/values, `break` outside loops, `try` without `except`, a modification without `let`, etc. are
92
- syntax errors (exit code 2).
93
-
94
- ## Restrictions
95
-
96
- Two layers: the LLM is told about active restrictions in the prompt, and the runtime enforces them. The LLM only gets
97
- the five runtime tools (`read_file`, `write_file`, `web_fetch`, `web_search`, `shell`); every call is checked against
98
- the active `restrict` scopes and any other Copilot permission request is rejected. A violation raises a
99
- `restriction_violation` failure (catchable with `try/except`).
100
-
101
- * Paths are canonicalized (symlinks resolved, `..` removed) relative to `--cwd` before the check.
102
- * `net` matches exact hosts (and `*.example.com` wildcards); redirects are re-checked on every hop. `web_search`
103
- needs `html.duckduckgo.com` to be allowed.
104
- * `exec` matches the executable name. While any restriction is active, `shell` accepts only simple commands (no
105
- `&|;<>` redirects, substitution or chaining) so the check is exact; path-like and URL arguments are also checked
106
- against `fs`/`net`. This is best effort for programs that touch files or the network on their own.
107
- * Nested restrictions of one kind intersect and can never widen.
108
-
109
- ## Reproducibility
110
-
111
- Control flow, arithmetic and interpolation are deterministic. The Copilot SDK has no `temperature` or `seed` setting,
112
- so the language has none either. `think` levels map to the SDK reasoning effort; if the selected model does not support
113
- it a warning is logged and the model default is used. Guard against variance with `assert` and `fmt='object'`.
114
-
115
- ## Variable store
116
-
117
- `--store vars.json` loads top-level variables from the file at start and writes them back at exit (also on failure).
118
- `--store-module ./mystore.mjs` plugs in your own store: export `createStore()` returning `{ load(): Record, save(vars) }`.
119
-
120
- ## Examples
121
-
122
- All in [examples/](examples); run from the project root, `--cwd <dir>` points a script at another project. The example
123
- syntax is checked by `tests/examples.test.ts`; `triage-issues` is also run on Copilot there.
124
-
125
- | Script | Shows |
126
- |--------|-------|
127
- | `commit-message.spl` | `shell` under `restrict exec 'git'`, validation helper, retry loop |
128
- | `triage-issues.spl` | `fmt='object'` + `assert` validation with feedback retries, aggregation, summary |
129
- | `extract-invoice.spl` | structured extraction cross-checked with arithmetic before it is accepted |
130
- | `code-review.spl` | `think` then structure, `restrict` on read and on write, `fail` as a CI gate |
131
- | `release-notes.spl` | nested `forbid`/`enforce`, git history to a file inside `./out` |
132
- | `web-digest.spl` | `restrict net`, `web_fetch`, catching a blocked second host |
133
- | `fix-failing-tests.spl` | agent loop: run `npm test`, let the LLM fix the source, repeat |
134
- | `translate-docs.spl` | `write_file` without `content` (LLM writes, path pinned), dict iteration |
135
- | `journal.spl` | state across runs with `--store` |
136
- | `basics.spl`, `live.spl`, `restrict-live.spl`, `tools-live.spl` | small feature tours |
137
-
138
- ## Develop
77
+ The default suite is offline. In PowerShell, opt into authenticated live checks with
78
+ `$env:SPL_LIVE_TESTS='1'; npm test`, then remove the environment variable.
139
79
 
140
- ```
141
- npm test # builds, then runs parser, interpreter, restriction and CLI tests, plus live tests on Copilot
142
- ```
80
+ The package exports `parse`, `run`, `Logger`, `CopilotBackend`, `RecordingBackend`,
81
+ `ReplayBackend`, variable stores, authentication helpers, and related types.
82
+ Call `close()` on the backend after running; replay also checks for unused entries there.
83
+ Custom backends must honor the request kind: only `agent` may execute tools.
84
+ See [embedding](../docs/internals.html#embedding).
143
85
 
144
- The live tests (`tests/live.test.ts`, one example in `tests/examples.test.ts`) call the real GitHub Copilot service and
145
- are skipped automatically when the runtime is not authenticated: run `spl login` first to include them.
86
+ ## Migration
146
87
 
147
- The VS Code extension (syntax highlighting, snippets) lives in `../vscode`.
88
+ 0.3 intentionally removes `enforce`/`forbid`, `think{level=...}`, tool-command syntax,
89
+ and `let` before compound/member/index assignments. Use `instruct`/`avoid`,
90
+ `reasoning`, ordinary raw functions, and explicit agents instead.
91
+ Bare blocks no longer create scopes. See the [migration table](../README.md#breaking-changes-in-03).
package/dist/ast.d.ts CHANGED
@@ -61,29 +61,16 @@ export type Expr = {
61
61
  lo?: Expr;
62
62
  hi?: Expr;
63
63
  } | {
64
- t: 'ask';
65
- mods: AskMods;
64
+ t: 'ask' | 'think' | 'agent';
65
+ mods: Record<string, Expr>;
66
66
  prompt: Expr;
67
- } | {
68
- t: 'think';
69
- mods: ThinkMods;
70
- prompt: Expr;
71
- } | {
72
- t: 'tool';
73
- tool: ToolName;
74
- params: Record<string, Expr>;
75
- instructions: Expr;
76
67
  };
77
68
  export interface AskMods {
78
69
  len?: 'word' | 'sentence' | 'paragraph' | 'long';
79
70
  fmt?: 'text' | 'object';
80
71
  }
81
- export interface ThinkMods {
82
- level?: 'low' | 'medium' | 'high';
83
- show?: boolean;
84
- }
85
72
  export type ToolName = 'read_file' | 'write_file' | 'web_fetch' | 'web_search' | 'shell';
86
- export type RestrictKind = 'fs' | 'net' | 'exec';
73
+ export type RestrictKind = 'fs' | 'net' | 'exec' | 'mcp';
87
74
  export type AssignTarget = {
88
75
  t: 'name';
89
76
  name: string;
@@ -101,6 +88,10 @@ interface Base {
101
88
  }
102
89
  export type Stmt = Base & ({
103
90
  t: 'let';
91
+ name: string;
92
+ value: Expr;
93
+ } | {
94
+ t: 'assign';
104
95
  target: AssignTarget;
105
96
  op: '=' | '+=' | '-=' | '*=' | '/=' | '//=' | '%=';
106
97
  value: Expr;
@@ -157,13 +148,15 @@ export type Stmt = Base & ({
157
148
  value?: Expr;
158
149
  } | {
159
150
  t: 'constraint';
160
- kind: 'enforce' | 'forbid';
151
+ kind: 'instruct' | 'avoid';
161
152
  text: Expr;
162
153
  body: Stmt[];
163
154
  } | {
164
155
  t: 'restrict';
165
- kind: RestrictKind;
166
- arg: Expr;
156
+ clauses: {
157
+ kind: RestrictKind;
158
+ arg: Expr;
159
+ }[];
167
160
  body: Stmt[];
168
161
  });
169
162
  export interface Directives {
package/dist/cli.js CHANGED
@@ -4,12 +4,15 @@ import * as path from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { CopilotBackend } from './copilot.js';
6
6
  import { AuthError, login, logout } from './auth.js';
7
- import { SplSyntaxError } from './errors.js';
7
+ import { Failure, SplSyntaxError } from './errors.js';
8
+ import { LlmError } from './llm.js';
8
9
  import { Logger } from './logger.js';
9
10
  import { parse } from './parser.js';
10
11
  import { run } from './interpreter.js';
11
12
  import { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
12
13
  import { toJson } from './values.js';
14
+ import { loadMcpConfig } from './mcp.js';
15
+ import { RecordingBackend, ReplayBackend } from './recording.js';
13
16
  const HELP = `spl - Semantic Prompt Language interpreter
14
17
 
15
18
  Usage: spl <script.spl> [options]
@@ -18,13 +21,17 @@ Usage: spl <script.spl> [options]
18
21
 
19
22
  Options:
20
23
  --log-level <level> error | info | debug (default: info). Logs go to stderr.
24
+ All levels include LLM transcripts and [deterministic] statement traces.
21
25
  --silent suppress all logs; only the result is printed
22
26
  --model <id> model override (takes precedence over @{model=...})
27
+ --mcp <file.json> MCP servers in VS Code mcp.json format
23
28
  --store <file.json> persist top-level variables in a JSON file
24
29
  --store-module <path> custom variable store: module exporting createStore() -> { load(), save(vars) }
25
30
  --timeout <seconds> per LLM call timeout (default: 300)
31
+ --record <file.jsonl> record generation responses (new file; no agents)
32
+ --replay <file.jsonl> offline generation replay; only local reads allowed
26
33
  --cwd <dir> base directory for relative paths and restrictions (default: current directory)
27
- --check parse the script and exit
34
+ --check validate script syntax and any --mcp config without executing
28
35
  -v, --version print version
29
36
  -h, --help print this help
30
37
 
@@ -72,9 +79,12 @@ async function main() {
72
79
  'log-level': { type: 'string' },
73
80
  silent: { type: 'boolean' },
74
81
  model: { type: 'string' },
82
+ mcp: { type: 'string' },
75
83
  store: { type: 'string' },
76
84
  'store-module': { type: 'string' },
77
85
  timeout: { type: 'string' },
86
+ record: { type: 'string' },
87
+ replay: { type: 'string' },
78
88
  cwd: { type: 'string' },
79
89
  check: { type: 'boolean' },
80
90
  version: { type: 'boolean', short: 'v' },
@@ -110,6 +120,10 @@ async function main() {
110
120
  usageError('--timeout must be a positive number');
111
121
  if (values.store && values['store-module'])
112
122
  usageError('--store and --store-module are mutually exclusive');
123
+ if (values.record && values.replay)
124
+ usageError('--record and --replay are mutually exclusive');
125
+ if (values.replay && (values.store || values['store-module']))
126
+ usageError('--replay cannot use a persistent variable store');
113
127
  const cwd = path.resolve(values.cwd ?? process.cwd());
114
128
  const file = positionals[0];
115
129
  let source;
@@ -130,6 +144,13 @@ async function main() {
130
144
  }
131
145
  throw e;
132
146
  }
147
+ let mcpServers;
148
+ try {
149
+ mcpServers = values.mcp ? loadMcpConfig(values.mcp, cwd) : undefined;
150
+ }
151
+ catch (e) {
152
+ return usageError(e.message);
153
+ }
133
154
  if (values.check)
134
155
  return 0;
135
156
  let store;
@@ -139,22 +160,46 @@ async function main() {
139
160
  catch (e) {
140
161
  return usageError(e.message);
141
162
  }
142
- const backend = new CopilotBackend({ logger, cwd, timeoutMs: timeoutSec * 1000 });
163
+ let backend;
143
164
  try {
144
- const result = await run(program, { backend, logger, store, cwd, model: values.model });
145
- if (result.status === 'failure') {
146
- process.stdout.write(JSON.stringify(result.failure, null, 4) + '\n');
147
- return 1;
165
+ if (values.replay)
166
+ backend = new ReplayBackend(values.replay);
167
+ else {
168
+ backend = new CopilotBackend({ logger, cwd, timeoutMs: timeoutSec * 1000, mcpServers });
169
+ if (values.record)
170
+ backend = new RecordingBackend(backend, values.record);
148
171
  }
149
- if (result.hasValue) {
150
- const v = result.value;
151
- process.stdout.write((typeof v === 'string' ? v : JSON.stringify(toJson(v), null, 2)) + '\n');
152
- }
153
- return 0;
172
+ }
173
+ catch (e) {
174
+ if (!(e instanceof Error))
175
+ throw e;
176
+ return usageError(`cannot open recording: ${e.message}`);
177
+ }
178
+ let result;
179
+ try {
180
+ result = await run(program, { backend, logger, store: values.replay ? undefined : store, cwd, model: values.model });
154
181
  }
155
182
  finally {
156
- await backend.close();
183
+ try {
184
+ await backend.close();
185
+ }
186
+ catch (e) {
187
+ if (!(e instanceof LlmError))
188
+ throw e;
189
+ logger.error(e.message);
190
+ if (result?.status !== 'failure')
191
+ result = { status: 'failure', failure: new Failure('llm_error', e.message).payload() };
192
+ }
193
+ }
194
+ if (result.status === 'failure') {
195
+ process.stdout.write(JSON.stringify(result.failure, null, 4) + '\n');
196
+ return 1;
197
+ }
198
+ if (result.hasValue) {
199
+ const v = result.value;
200
+ process.stdout.write((typeof v === 'string' ? v : JSON.stringify(toJson(v), null, 2)) + '\n');
157
201
  }
202
+ return 0;
158
203
  }
159
204
  main().then((code) => process.exit(code), (e) => {
160
205
  process.stderr.write(`spl: internal error: ${e.stack ?? e}\n`);
package/dist/copilot.d.ts CHANGED
@@ -3,10 +3,12 @@ import { Failure } from './errors.js';
3
3
  import { type LlmBackend, type LlmRequest, type LlmResponse } from './llm.js';
4
4
  import type { Logger } from './logger.js';
5
5
  import type { Restrictions } from './restrict.js';
6
+ import { type McpServers } from './mcp.js';
6
7
  export interface CopilotBackendOptions {
7
8
  logger: Logger;
8
9
  cwd: string;
9
10
  timeoutMs: number;
11
+ mcpServers?: McpServers;
10
12
  }
11
13
  export declare class CopilotBackend implements LlmBackend {
12
14
  private readonly opts;
@@ -14,6 +16,7 @@ export declare class CopilotBackend implements LlmBackend {
14
16
  private models?;
15
17
  constructor(opts: CopilotBackendOptions);
16
18
  private getClient;
19
+ private denyGenerationPermission;
17
20
  private listModels;
18
21
  /** Maps the `think` level to the SDK reasoning effort, warning when the model cannot honor it. */
19
22
  private resolveEffort;
@@ -23,7 +26,7 @@ export declare class CopilotBackend implements LlmBackend {
23
26
  close(): Promise<void>;
24
27
  }
25
28
  /**
26
- * Hard enforcement at the permission layer. Only the five runtime tools are exposed to the model, so any
27
- * other request is denied; requests that would violate a restriction are additionally reported as violations.
29
+ * Only runtime tools and explicitly configured MCP tools are approved.
30
+ * Requests that violate a restriction are additionally reported as violations.
28
31
  */
29
- export declare function decidePermission(r: PermissionRequest, restrictions: Restrictions, block: (f: Failure) => void): PermissionRequestResult;
32
+ export declare function decidePermission(r: PermissionRequest, restrictions: Restrictions, block: (f: Failure) => void, mcpServers?: McpServers): PermissionRequestResult;
package/dist/copilot.js CHANGED
@@ -3,10 +3,11 @@ import { loadToken } from './auth.js';
3
3
  import { Failure } from './errors.js';
4
4
  import { LlmError } from './llm.js';
5
5
  import { TOOL_SPECS } from './tools.js';
6
+ import { configuredMcpTool, filterMcpServers } from './mcp.js';
6
7
  const SYSTEM_MESSAGE = [
7
8
  'You are the execution engine of an automated script. Your reply is stored in a variable and used by the program.',
8
9
  'Reply with exactly what was asked for: no preamble, no sign-off, no commentary.',
9
- 'Use the provided tools (read_file, write_file, web_fetch, web_search, shell) only when the task needs them. No other tools exist.',
10
+ 'Use the provided runtime tools and configured MCP tools only when the task needs them.',
10
11
  ].join(' ');
11
12
  const TOOL_NAMES = Object.keys(TOOL_SPECS);
12
13
  export class CopilotBackend {
@@ -35,6 +36,11 @@ export class CopilotBackend {
35
36
  }
36
37
  return this.client;
37
38
  }
39
+ denyGenerationPermission(block) {
40
+ const failure = new Failure('restriction_violation', 'generation cannot use tools; use an explicit agent operation');
41
+ block(failure);
42
+ return { kind: 'reject', feedback: failure.reason };
43
+ }
38
44
  listModels(client) {
39
45
  this.models ??= client.listModels().catch((e) => {
40
46
  this.opts.logger.debug(`could not list models: ${e.message}`);
@@ -75,7 +81,8 @@ export class CopilotBackend {
75
81
  violation ??= failure;
76
82
  void session?.abort().catch(() => undefined);
77
83
  };
78
- const tools = TOOL_NAMES.map((name) => {
84
+ const agent = req.kind === 'agent';
85
+ const tools = (agent ? TOOL_NAMES : []).map((name) => {
79
86
  const spec = TOOL_SPECS[name];
80
87
  return defineTool(name, {
81
88
  description: spec.description,
@@ -83,8 +90,6 @@ export class CopilotBackend {
83
90
  parameters: { type: 'object', properties: spec.properties, required: spec.required, additionalProperties: false },
84
91
  handler: async (args) => {
85
92
  const params = { ...args };
86
- if (req.pinned?.tool === name)
87
- Object.assign(params, req.pinned.params);
88
93
  try {
89
94
  return await req.tools.invoke(name, params);
90
95
  }
@@ -100,13 +105,15 @@ export class CopilotBackend {
100
105
  },
101
106
  });
102
107
  });
108
+ const mcpServers = agent ? filterMcpServers(this.opts.mcpServers ?? {}, req.restrictions) : {};
103
109
  const baseConfig = {
104
110
  model: req.model,
105
111
  tools,
106
- availableTools: TOOL_NAMES,
112
+ availableTools: [...(agent ? TOOL_NAMES : []), ...Object.keys(mcpServers).map((name) => `mcp:${name}-*`)],
113
+ mcpServers,
107
114
  systemMessage: { mode: 'append', content: SYSTEM_MESSAGE },
108
115
  workingDirectory: this.opts.cwd,
109
- onPermissionRequest: (r) => decidePermission(r, req.restrictions, block),
116
+ onPermissionRequest: (r) => agent ? decidePermission(r, req.restrictions, block, mcpServers) : this.denyGenerationPermission(block),
110
117
  };
111
118
  try {
112
119
  try {
@@ -160,11 +167,17 @@ export class CopilotBackend {
160
167
  }
161
168
  }
162
169
  /**
163
- * Hard enforcement at the permission layer. Only the five runtime tools are exposed to the model, so any
164
- * other request is denied; requests that would violate a restriction are additionally reported as violations.
170
+ * Only runtime tools and explicitly configured MCP tools are approved.
171
+ * Requests that violate a restriction are additionally reported as violations.
165
172
  */
166
- export function decidePermission(r, restrictions, block) {
173
+ export function decidePermission(r, restrictions, block, mcpServers = {}) {
167
174
  try {
175
+ if (r.kind === 'mcp') {
176
+ restrictions.checkMcp(r.serverName, r.toolName);
177
+ if (configuredMcpTool(mcpServers, r.serverName, r.toolName))
178
+ return { kind: 'approve-once' };
179
+ return { kind: 'reject', feedback: 'MCP server/tool is not enabled by --mcp configuration.' };
180
+ }
168
181
  if (r.kind === 'custom-tool' && TOOL_NAMES.includes(r.toolName)) {
169
182
  return { kind: 'approve-once' }; // the tool itself performs the restriction checks
170
183
  }
@@ -188,5 +201,5 @@ export function decidePermission(r, restrictions, block) {
188
201
  return { kind: 'reject', feedback: JSON.stringify(e.payload()) };
189
202
  }
190
203
  }
191
- return { kind: 'reject', feedback: 'Only the read_file, write_file, web_fetch, web_search and shell tools may be used.' };
204
+ return { kind: 'reject', feedback: 'Only runtime tools and configured MCP tools may be used.' };
192
205
  }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type StatusCode = 'assertion_failed' | 'explicit_failure' | 'invalid_format' | 'restriction_violation' | 'llm_error' | 'tool_error' | 'runtime_error';
1
+ export type StatusCode = 'assertion_failed' | 'explicit_failure' | 'invalid_format' | 'validation_failed' | 'restriction_violation' | 'llm_error' | 'tool_error' | 'runtime_error';
2
2
  export interface FailurePayload {
3
3
  status: 'failure';
4
4
  reason: string;
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export { run, type RunOptions, type RunResult } from './interpreter.js';
3
3
  export { Failure, SplSyntaxError, type FailurePayload, type StatusCode } from './errors.js';
4
4
  export { Logger, type LogLevel } from './logger.js';
5
5
  export { CopilotBackend } from './copilot.js';
6
+ export { RecordingBackend, ReplayBackend } from './recording.js';
7
+ export { loadMcpConfig, type McpServers } from './mcp.js';
6
8
  export { login, logout, authStatus, AuthError } from './auth.js';
7
9
  export { JsonFileStore, MemoryStore, loadStoreModule, type VariableStore } from './store.js';
8
10
  export type { LlmBackend, LlmRequest, LlmResponse } from './llm.js';
package/dist/index.js CHANGED
@@ -3,5 +3,7 @@ export { run } from './interpreter.js';
3
3
  export { Failure, SplSyntaxError } from './errors.js';
4
4
  export { Logger } from './logger.js';
5
5
  export { CopilotBackend } from './copilot.js';
6
+ export { RecordingBackend, ReplayBackend } from './recording.js';
7
+ export { loadMcpConfig } from './mcp.js';
6
8
  export { login, logout, authStatus, AuthError } from './auth.js';
7
9
  export { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';