Skip to main content
Glama
mgannotti

Scout Plugin Hybrid Routing

by mgannotti

Hybrid Contextual Inference — Microsoft Scout Plugin

Route each task to the right model across a hybrid stack — Scout's hosted cloud models, on-device runtimes, and org-hosted endpoints — and keep sensitive content inside the trust boundary it belongs in.

A Scout port of smfworks/hermes-plugin-hybrid-routing, rebuilt for Scout's extension model and hardened around the thing that actually makes hybrid inference worth doing: provable egress control.

The three-signal design, the difficulty heuristics and the config schema originate upstream (MIT, © 2026 SMF Works). See ATTRIBUTION.md for exactly what was carried over and what is new here.


What it does

Classifies every task on three signals, then picks a model:

Signal

Values

Drives

sensitivity

normal / confidential / restricted

which trust boundary the content may cross

role

coding, research, creative, strategy, vision, general

model specialization

difficulty

simple / standard / hard

fast / balanced / strong tier

The primary session model never changes, so prompt caching survives. Tasks that need a different model are delegated to a Scout subagent, or executed against a local / org-hosted endpoint.

Related MCP server: Router-MCP

Egress classes

This is the core concept, and the main departure from the Hermes original. There, "local" is a naming convention inside an opaque provider/model string. Here every model reference declares where inference physically happens:

Reference

Egress class

Executed via

scout/cloud-strong

cloud-public

Scout's task tool

org/tenant/gpt-4o

org-tenant

OpenAI-compatible HTTP

local/ollama/qwen3:8b

on-device

OpenAI-compatible HTTP

Sensitivity sets a ceiling, and both model selection and the fallback chain are filtered by it:

normal        →  cloud-public permitted
confidential  →  org-tenant or tighter
restricted    →  on-device only

If no configured model satisfies the ceiling, the router blocks. It never degrades to a less-trusted model.

Differences from the Hermes original

Three defects were found by running the upstream engine before porting. Each has a named regression test in tests/test_hybrid_routing.py.

#

Severity

Bug

Fix

1

critical

Sensitive content with no local model configured falls through to the balanced cloud tier (router.py:253-255), and the hardcoded default ollama-cloud/glm-5.2 is a cloud endpoint. The feature fails open.

Fails closed — returns status: blocked with no model.

2

high

All tier models are appended to the fallback chain after the sensitivity branch (router.py:270-273), so a sensitive decision carries cloud models as fallbacks.

Fallback chains are egress-filtered; a restricted decision contains only on-device candidates.

3

medium

Role cues match as bare substrings, so the cue test fires on "contest", "greatest", "latest" and misroutes ordinary prose to the coding model.

Cues match on word boundaries.

4

medium

simple_cues is the only pattern set compiled without re.IGNORECASE (router.py:133), so capitalized greetings miss the fast tier and land on the paid balanced tier.

All cue sets are case-insensitive.

Line references are pinned to upstream commit 88198cf. All four were reported upstream and are being addressed — see ATTRIBUTION.md.

Plus: the upstream run_tests() evaluates against whichever config is loaded, so its expectations depend on the user's own model choices — with a plausible hybrid config it self-reports 6/9. Here the self-test pins its own reference config, so a failure always means the engine changed.

Second-pass findings (v1.1)

A self-review after v1.0.0 found five more bugs — in this code, not upstream. All 86 v1.0.0 tests passed while every one of them was live, because the tests were written by the author of the code and inherited its blind spots.

#

Severity

Bug

Fix

1

critical

A reference's egress came only from its scheme prefix, while the actual network destination came from the backend's base_url. The two were never reconciled, so local/x/model pointing at a backend declared cloud-public was routed as on-device and validate() returned clean. Restricted content would have been POSTed to a public endpoint.

resolve_egress() reconciles scheme, backend-declared egress and URL, taking the least trusted of the three

2

critical

A local/ backend whose base_url was a remote host was still treated as on-device. Nothing checked that "local" meant localhost.

an on-device claim now requires a loopback address, else it degrades to org-tenant

3

high

Audit signals truncated a match to 12 characters — but the SSN pattern matches exactly 11, so SSNs and short MRNs were echoed verbatim into the decision record. The v1 test used a long secret and passed.

signals now report the match's shape (###-##-####), masking every alphanumeric

4

medium

policy.max_egress was compared literally, so On-Device, ondevice or any typo silently disabled the cap — a fail-open on the one setting whose entire job is locking things down.

normalized case/whitespace; unrecognized values fail closed to on-device and are reported by validate()

5

medium

A model reference naming an undefined backend validated clean and routed ok, failing only at execution time.

undefined backends resolve to cloud-public and are flagged

The lesson generalizes: bug 1 is the same class of failure as the upstream fail-open — a trust decision made from a value that does not actually control where the bytes go. Fixing someone else's version of a bug is no protection against writing your own.

Each fix has a regression test, verified by mutation testing: reverting resolve_egress to the v1 behaviour fails 5 tests, and reverting _redact fails the short-secret cases.

Third-pass findings (v1.2)

An independent reviewer found eight more. The theme this time: routing itself was sound, but nothing enforced it at the point of transmission, and several ways of silently deleting a control went unreported.

#

Severity

Bug

Fix

1

high

route_infer accepted any backend, model and prompt with no classification at all. A caller could hand a restricted prompt straight to a cloud-configured backend and never consult the router — making the entire egress model decorative.

authorize_inference() re-classifies at send time and refuses when the backend's resolved egress exceeds the ceiling; enforced in both the CLI and the MCP tool

2

high

On-device traffic could escape through an HTTP proxy: urlopen honours $HTTP_PROXY and does not exclude localhost unless $NO_PROXY says so. A proxy would receive the full restricted prompt.

loopback requests use an empty ProxyHandler and refuse redirects

3

medium

A backend with no egress: key at all silently inherited the scheme's claim, so org/tenant/m pointing at a public API was accepted for confidential content. Backend.from_config separately defaulted to on-device.

a missing or unreadable egress resolves to cloud-public; the dataclass default is now the least-trusted class

4

medium

Confidential prompts and Authorization headers could travel over cleartext http:// to a non-loopback host, and it validated clean.

non-loopback cleartext degrades to cloud-public and is reported

5

medium

except re.error: continue silently discarded an invalid sensitivity pattern. A typo'd protection rule vanished while the config still claimed the content was covered.

compile failures are collected and surfaced by validate() with the rule index

6

medium

Unknown config keys were ignored, so sensitivty: deleted every detection pattern and max_egres: removed the global cap — both validating clean.

known-key checking on top-level and nested sections

7

medium

Signal masking replaced alphanumerics but kept punctuation verbatim, so a symbol-only credential (secret=+-*/) was echoed intact.

every non-space character is masked

8

low

Malformed YAML, bad UTF-8 and non-numeric timeouts escaped as tracebacks.

translated into ConfigError / BackendError

Fourth-pass findings (v1.3.1)

Both defects sit in the one payment-card rule carried over from the Hermes original, and both were still present upstream at the time of writing (hybrid_contextual_routing/data/routing_config.yaml:131). The old rule was "\b(?:\d[ -]?){13,16}\b".

#

Severity

Bug

Fix

1

high

The {13,16} ceiling plus \b on both ends meant a contiguous run longer than 16 digits offered no position where the rule could start, so 17/18/19-digit PANs matched nothing at all — silently, not truncated. ISO/IEC 7812 permits PANs up to 19 digits, and 19-digit Visa (VPay) and UnionPay cards are in issue. A real card number reached a cloud model with nothing raised.

range widened to 13–19 via "\b(?:\d[ -]?){12,18}\d\b"; the trailing \d also stops the match swallowing a trailing separator

2

medium

No value check, so any 13–16 digit run was classified restricted: ISBNs, order references, and columns of figures. Because a separator is allowed after every digit, nine two-digit numbers read as one run. Restricted content is blocked when no on-device model is configured, so ordinary documents stopped work.

the rule declares validator: luhn in the config; a match is only honoured if its digits satisfy the Luhn checksum

Pattern entries therefore accept a mapping as well as a plain string, so the value check stays visible in the config rather than hidden in Python:

restricted_patterns:
  - "\\b\\d{3}-\\d{2}-\\d{4}\\b"          # plain regex, unchanged
  - pattern: "\\b(?:\\d[ -]?){12,18}\\d\\b"
    validator: luhn                        # only match if Luhn-valid

An unknown validator name is reported by validate() and the rule is kept matching rather than dropped, consistent with third-pass finding 5: a protection rule that silently vanishes is worse than one that over-fires.

Luhn is a checksum, not proof of a card: roughly one in ten random digit runs of a given length passes it. It removes the bulk of the false positives, not all of them.

Provenance

Content read from an M365 surface is treated as business content by default, even when the text carries no marker. How much that should matter is deployment-specific, so it is configurable:

provenance:
  mode: floor              # floor (default) | advisory | off
  default_level: confidential
  sources:                 # optional per-source overrides
    transcript: restricted
    calendar: normal

Mode

Behaviour

floor

Anything from an M365 surface is raised to default_level, regardless of content. The default.

advisory

The source is recorded in the decision signals but does not raise the level. Labels and content patterns still apply in full.

off

Provenance is ignored entirely.

sources entries win over mode in both directions — they can exempt a surface the mode would floor, or floor one it would ignore. Set a source to normal to exempt it.

Which to pick. floor is right for regulated environments where any tenant-sourced content needs containment. advisory suits an individual whose M365 traffic is mostly routine: a blanket floor sends every email summary to the sensitivity model — typically the smallest one available — and the quality drop tends to make people disable the feature outright, which protects nothing.

Under advisory the label and pattern signals are untouched, so a document labelled Highly Confidential or an email containing a key still routes to the restricted model.

An unreadable mode, default_level or source override fails closed to floor / confidential and is reported by status.

Limitations — read this before trusting it

Classification happens after the session model has already read the content. This plugin routes execution. If Scout's cloud session model has already received a confidential email in its context, calling route_classify on it afterwards cannot un-disclose it — the decision only governs where the follow-on work runs. Genuine prevention would require classification as host-side middleware ahead of the first model invocation, which is outside what a plugin can reach. Treat this as a control over delegation and execution, not as a guarantee that the primary model never sees sensitive text.

org-tenant is asserted, not verified. Nothing proves an endpoint you declare as tenant-hosted actually is. The router checks the URL is HTTPS and non-loopback, and that on-device claims resolve to loopback — it cannot verify tenancy. That declaration is yours to get right.

Detection is pattern-based. It will miss sensitive content that carries no marker, no label and no M365 provenance. Pass --label and --source whenever you have them; they are far stronger signals than the text scan.

Added for Scout

  • MIP sensitivity labels — pass --label "Highly Confidential" and the label drives routing directly. Checked most-restrictive-first, so "Highly Confidential" is not swallowed by the "Confidential" substring.

  • M365 provenance--source email|teams|sharepoint|calendar|transcript applies a confidential floor by default. Content read out of the tenant is business content even when the text looks benign. Tunable via the provenance config section (floor / advisory / off, plus per-source overrides) — see Provenance.

  • Real local execution — Hermes delegates model execution to its own provider layer. Scout's task tool hosts only Scout's cloud models, so this plugin ships an OpenAI-compatible client covering Ollama, LM Studio, Foundry Local, llama.cpp, vLLM and Azure AI Foundry.

  • Backend discoveryprobe reports what is reachable, including known local runtime ports that are running but unconfigured.

  • reasoning_effort passed through per tier and role.

  • Config validationstatus flags a restricted_model that is not actually on-device, and any reference it cannot parse.

  • Global policy cappolicy.max_egress caps all routing regardless of content, for an air-gapped or high-compliance profile.

  • Graded sensitivity — Hermes has binary normal/sensitive, which cannot express "internal business content, tenant-hosted model is fine". That distinction comes up constantly in Microsoft 365 work.

Install

pip install -r requirements.txt
python -m hybrid_routing init          # writes ~/.scout/hybrid_routing/routing_config.yaml
python -m hybrid_routing probe         # see which local runtimes are already up

Edit the config to fill in your models, then:

python -m hybrid_routing status        # validates and reports problems
python -m hybrid_routing test          # engine self-test

See setup.md for the skill and MCP server registration.

Usage

python -m hybrid_routing classify "Debug the import logic in this module"
python -m hybrid_routing classify --label "Highly Confidential" --source email "Summarize this thread"
python -m hybrid_routing infer --backend ollama --model qwen3:8b --prompt-file task.txt

Add --json for machine-readable output. Exit codes: 0 routed, 1 blocked, 2 not configured, 3 error.

Example — sensitive M365 content

$ python -m hybrid_routing classify --label "Highly Confidential" --source email \
      "Summarize this thread and list open questions"

  sensitivity   : restricted
  egress ceiling: on-device
  MODEL         : local/ollama/qwen3:8b
  channel       : openai-http
  delegate      : YES

  reason: Content classified 'restricted' -> routed to 'local/ollama/qwen3:8b'
          (on-device); egress ceiling 'on-device' enforced; 5 cloud/less-trusted
          model(s) excluded

  signals:
    - MIP label 'Highly Confidential' -> restricted
    - M365 provenance (email) -> confidential floor

  run: python -m hybrid_routing infer --backend ollama --model qwen3:8b --prompt-file <file>

Example — the same content with a cloud-only config

  ROUTING BLOCKED
  MODEL         : (none selected)

  reason: BLOCKED: content classified 'restricted' requires a model at or inside
          the 'on-device' boundary, and none is configured. 5 configured model(s)
          were rejected because their egress class exceeds the 'on-device'
          boundary. Routing to an available cloud model would send this content
          outside the required boundary.

  excluded (outside ceiling):
    x scout/cloud-balanced  — egress 'cloud-public' exceeds ceiling 'on-device'

Exit code 1. This is the case the upstream plugin silently sends to the cloud.

Tools (via MCP)

Registering the MCP server puts these in Scout's tool list on every turn:

Tool

Purpose

route_classify

classify a task and get a routing decision

route_status

show configuration and validation problems

route_test

run the engine self-test

route_probe

report reachable local / org backends

route_infer

run a prompt on a local or org backend

Architecture

hybrid_routing/
├── egress.py       egress classes, model reference parsing  ← the trust model
├── classify.py     sensitivity / role / difficulty, MIP labels, provenance
├── router.py       selection + egress filtering + fail-closed blocking
├── backends.py     OpenAI-compatible client, discovery (stdlib only)
├── config.py       config resolution and validation
├── selftest.py     engine self-test against a pinned reference config
├── cli.py          command line interface
└── mcp_server.py   MCP stdio server (JSON-RPC, no dependencies)

data/routing_config.yaml   shipped default — every model field blank
skill/                     Scout skill package
tests/                     164 tests

Design notes

Why blank by default. A router that guesses is a router that eventually sends something somewhere you did not intend. Nothing routes until you name a model. Unconfigured is reported, not silently defaulted.

Why unknown schemes raise. An unrecognized reference could be local or cloud. Guessing wrong on that single question is the entire failure mode this plugin exists to prevent, so it refuses to guess.

Why local models always delegate. skip_for_tier: [fast] can only apply to Scout-hosted models. A local model is unreachable from the primary session, so it is always executed out of band regardless of tier.

Why signals are redacted. Classification signals name the pattern that matched and echo at most 12 characters of it, so a decision record is auditable without reprinting the secret that triggered it.

Testing

python -m pytest        # 164 tests

License

MIT — © 2026 Michael Gannotti. Derivative of smfworks/hermes-plugin-hybrid-routing, MIT © 2026 SMF Works. See LICENSE and ATTRIBUTION.md.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • -
    license
    -
    quality
    -
    maintenance
    Intelligent routing service that selects optimal AI models based on capability requirements and normalizes input/output formats across multiple providers like OpenAI, Anthropic, Google, and others.
    Last updated
  • F
    license
    C
    quality
    D
    maintenance
    Routes natural language queries to appropriate MCP tools and planners with high-precision semantic matching and safety guardrails. Supports multi-intent detection, task planning, and strict role-based filtering to prevent misexecution or unauthorized access.
    Last updated
    7

View all related MCP servers

Related MCP Connectors

  • Enterprise AI Control Plane: governance, guardrails, spend tracking, compliance & smart routing.

  • Intent execution engine for autonomous agent task routing

  • Responsible-AI guardrails for agents: scoring with policy, injection & PII detection, DPDP.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mgannotti/scout-plugin-hybrid-routing'

If you have feedback or need assistance with the MCP directory API, please join our Discord server